Configuration Merging¶
Real applications rarely use a single configuration file. You need different settings for development, staging, and production. Your team might have shared defaults, but individual developers need their own local overrides. This is where configuration merging shines.
Let's learn how to split your configuration intelligently and merge it back together.
Why Split Configuration?¶
Imagine you're working on a web application. You have:
- Base settings that everyone shares (app name, API endpoints structure)
- Production settings (production database, external services)
- Your local development overrides (point database at localhost, enable debug mode)
You could put everything in one big file with lots of conditional logic. But that gets messy fast. Instead, let's split it across multiple files and merge them together.
Your First Merge: Base and Environment¶
Let's start with two files. First, create config/base.yaml with shared defaults:
# config/base.yaml
app:
name: my-application
debug: false
database:
host: localhost
port: 5432
pool_size: 10
logging:
level: info
format: json
Now create config/production.yaml with production-specific overrides:
Notice production only includes what's different. Let's merge them:
from holoconf import Config
# Load base configuration
config = Config.load("config/base.yaml")
# Load production overrides
production = Config.load("config/production.yaml")
# Merge production into base
config.merge(production)
# Now config contains the merged result
db_host = config.database.host
print(f"Database: {db_host}")
# Database: prod-db.example.com
db_port = config.database.port
print(f"Port: {db_port}")
# Port: 5432 (from base.yaml)
pool_size = config.database.pool_size
print(f"Pool size: {pool_size}")
# Pool size: 50 (overridden by production.yaml)
use holoconf::Config;
// Load base configuration
let mut config = Config::load("config/base.yaml")?;
// Load production overrides
let production = Config::load("config/production.yaml")?;
// Merge production into base
config.merge(production);
let db_host: String = config.get("database.host")?;
println!("Database: {}", db_host);
// Database: prod-db.example.com
Let's understand what happened:
database.hostwas overridden toprod-db.example.comdatabase.portkept its value from base (5432) because production didn't override itdatabase.pool_sizewas overridden to50logging.levelwas overridden towarning- Everything else (
app.name,app.debug,logging.format) stayed from base
How Merging Works¶
When you merge configurations, HoloConf uses these rules:
| Type | Behavior |
|---|---|
| Scalars (string, int, bool) | Later value replaces earlier |
| Objects/Maps | Deep merge (keys merged recursively) |
| Arrays | Later array replaces earlier (no concatenation) |
This means merging is "deep" for nested objects but "shallow" for arrays. Let's see an example:
# override.yaml
features:
auth:
providers: [local] # This replaces the entire array
analytics:
enabled: true
After merging:
features:
auth:
enabled: true # Kept from base
providers: [local] # Replaced by override
search:
enabled: true # Kept from base
analytics:
enabled: true # Added by override
Environment-Based Configuration¶
Now let's build a pattern you'll use all the time: environment-based configuration. Your directory structure:
Load the right config based on environment:
import os
from holoconf import Config
# Get environment from environment variable
env = os.environ.get("APP_ENV", "development")
# Load base config
config = Config.load("config/base.yaml")
# Merge environment-specific config
env_config = Config.load(f"config/{env}.yaml")
config.merge(env_config)
# Now use the merged config
db_host = config.database.host
print(f"Running in {env} with database {db_host}")
use holoconf::Config;
use std::env;
// Get environment from environment variable
let env_name = env::var("APP_ENV")
.unwrap_or_else(|_| "development".into());
// Load base config
let mut config = Config::load("config/base.yaml")?;
// Merge environment-specific config
let env_config = Config::load(&format!("config/{}.yaml", env_name))?;
config.merge(env_config);
let db_host: String = config.get("database.host")?;
println!("Running in {} with database {}", env_name, db_host);
This pattern gives you:
- Shared defaults in
base.yaml - Environment-specific overrides in
development.yaml,production.yaml, etc. - One simple switch (
APP_ENV) to control which config is loaded
Optional Files: Local Overrides¶
What about files that might not exist? For example, you want developers to be able to create a local.yaml file for their personal overrides, but you don't want to commit it to git.
If you try to load a missing file with Config.load(), you get an error:
Instead, use Config.optional():
Now developers can create config/local.yaml with their personal settings:
# config/local.yaml (not committed to git)
app:
debug: true
database:
host: localhost
logging:
level: debug
And add it to .gitignore:
Common Pattern: Three-Layer Configuration
A robust pattern uses three layers:
- Base - Shared defaults (committed)
- Environment - Environment-specific (committed)
- Local - Developer overrides (gitignored, optional)
Glob Patterns: Automatic Merging¶
Sometimes you have many config files and you want to merge them all automatically. Use glob patterns:
Supported patterns:
| Pattern | Matches |
|---|---|
* |
Any sequence of characters (except /) |
** |
Any sequence of directories |
? |
Any single character |
[abc] |
Any character in the set |
[a-z] |
Any character in the range |
Merge Order¶
Files matching a glob are sorted alphabetically before merging:
config/
├── 00-base.yaml # Loaded first
├── 10-database.yaml # Loaded second
├── 20-logging.yaml # Loaded third
└── 99-local.yaml # Loaded last (highest priority)
This lets you control merge order with numeric prefixes. The file loaded last wins for any conflicting values.
Let's see this in action:
Numeric Prefixes
Use numeric prefixes to make merge order explicit:
00-Base configuration10-,20-,30-Feature-specific configs99-Local overrides (highest priority)
Optional Globs¶
What if no files match your pattern? By default, Config.load() errors:
Use Config.optional() to return an empty config instead:
Putting It All Together¶
Here's a complete example using everything we've learned:
config/
├── 00-base.yaml # Base defaults
├── 10-database.yaml # Database config
├── 20-logging.yaml # Logging config
├── environments/
│ ├── development.yaml
│ ├── staging.yaml
│ └── production.yaml
└── local.yaml # .gitignored, optional
import os
from holoconf import Config
# Get environment
env = os.environ.get("APP_ENV", "development")
# Step 1: Load and merge base configs
config = Config.load("config/0*.yaml") # All files starting with 0, 1, 2
# Step 2: Merge environment-specific config
env_config = Config.load(f"config/environments/{env}.yaml")
config.merge(env_config)
# Step 3: Merge optional local overrides
local = Config.optional("config/local.yaml")
config.merge(local)
# Now use the fully merged config
print(f"Running in {env} environment")
print(f"Database: {config.database.host}")
print(f"Log level: {config.logging.level}")
use holoconf::Config;
use std::env;
let env_name = env::var("APP_ENV")
.unwrap_or_else(|_| "development".into());
// Load and merge base configs
let mut config = Config::load("config/0*.yaml")?;
// Merge environment-specific
let env_config = Config::load(&format!("config/environments/{}.yaml", env_name))?;
config.merge(env_config);
// Merge optional local
let local = Config::optional("config/local.yaml")?;
config.merge(local);
println!("Running in {} environment", env_name);
This gives you maximum flexibility:
- Shared defaults in numbered files
- Environment-specific overrides
- Personal local overrides
- All merged automatically
Try It Yourself
Set up a multi-file configuration:
- Create
config/00-base.yamlwith basic settings - Create
config/10-database.yamlwith database config - Create
config/environments/development.yamlwith dev settings - Create
config/local.yamlwith your personal overrides - Load and merge them all!
What You've Learned¶
You now understand:
- How to merge two configurations together
- Deep merge behavior for objects vs shallow for arrays
- Environment-based configuration patterns
- Optional files for local overrides
- Glob patterns for automatic merging
- Controlling merge order with numeric prefixes
- Building robust multi-layer configuration systems
Next Steps¶
- Validation - Validate your merged configuration with JSON Schema
- ADR-004 Config Merging - Design rationale for merge behavior