Reliable Deep Merges for PowerShell Hashtables: Safe Defaults, Predictable Overrides
You often need to overlay environment-specific settings onto safe defaults without accidentally blowing away nested keys. In PowerShell, a shallow merge will trample child properties and lead to surprising regressions. The fix is small, predictable, and testable: a tiny recursive merge for hashtables that returns a new object, preserves nested keys, and treats arrays as atomic (replace-as-a-whole) to avoid subtle surprises.
Design Goals for a Reliable Merge
- Keep safe defaults: Start with a base configuration and selectively override only what changes per environment.
- Deep (recursive) merge: When both sides have a hashtable at the same key, merge those hashtables instead of replacing the entire subtree.
- Arrays replace as a whole: Merging arrays element-by-element is error-prone. Replacing arrays atomically makes behavior predictable and safer for versioned config.
- Return a new object: Don’t mutate callers’ inputs. Return a fresh hashtable for safer composition and easier testing.
- Deterministic ordering: Use
[ordered]to keep key order stable, which helps with readable diffs and code reviews.
The Tiny Recursive Merge Function
This implementation is intentionally minimal. It prefers clarity and predictable behavior over clever tricks.
function Merge-Hashtable {
param(
[hashtable]$Default,
[hashtable]$Override
)
$out = [ordered]@{}
foreach ($k in $Default.Keys) { $out[$k] = $Default[$k] }
foreach ($k in $Override.Keys) {
if ($out.ContainsKey($k) -and ($out[$k] -is [hashtable]) -and ($Override[$k] -is [hashtable])) {
$out[$k] = Merge-Hashtable -Default $out[$k] -Override $Override[$k]
} else {
$out[$k] = $Override[$k]
}
}
$out
}
# Example
$base = @{
Api = @{ Base='api.example.local'; TimeoutSec=15 }
Features = @{ Search = $true; Reports = $false }
Tags = @('base')
}
$env = @{
Api = @{ TimeoutSec=30 }
Features = @{ Reports = $true }
Tags = @('prod')
}
$cfg = Merge-Hashtable -Default $base -Override $env
$cfg | ConvertTo-Json -Depth 5What this does
- Nested hashtable merge:
Apiremains a table; onlyTimeoutSecis overridden, whileBaseis preserved. - Predictable arrays:
Tagsbecomes@('prod')because arrays replace entirely. - Immutability:
$baseand$envremain unchanged;$cfgis a new object.
Expected output (abridged)
{
"Api": {
"Base": "api.example.local",
"TimeoutSec": 30
},
"Features": {
"Search": true,
"Reports": true
},
"Tags": [
"prod"
]
}Behavior with arrays, nulls, and type mismatches
- Arrays: Always replaced by the override array. If you need union/intersection semantics, implement that explicitly to avoid surprising configuration drift.
- Nulls: If the override sets a key to
$null, the result will have$nullfor that key—useful for disabling a default. If you want to skip nulls, add a conditional to ignore them. - Type mismatches: If default is a hashtable and override is a string (or vice versa), the override wins. This is explicit and predictable; you can add type validation if you want stricter behavior.
Hardening the Pattern for Real Projects
1) Input validation and casing
- Case-insensitive keys: PowerShell hashtables are case-insensitive by default, which is usually desirable for config. If you need case-sensitive behavior, use
[System.Collections.Specialized.OrderedDictionary]with a case-sensitive comparer and adapt the function. - Type checks: If your config must be strictly typed, validate schemas before merging (e.g., ensure
TimeoutSecis an[int]). You can throw early to fail fast in CI.
2) Testing immutability and array semantics
Write a few small Pester tests to lock in behavior.
Describe 'Merge-Hashtable' {
It 'returns a new object and does not mutate inputs' {
$a = @{ A = @{ B = 1 }; L = @(1,2) }
$b = @{ A = @{ C = 2 }; L = @(9) }
$copyA = $a | ConvertTo-Json -Depth 5
$copyB = $b | ConvertTo-Json -Depth 5
$m = Merge-Hashtable -Default $a -Override $b
($a | ConvertTo-Json -Depth 5) | Should -Be $copyA
($b | ConvertTo-Json -Depth 5) | Should -Be $copyB
$m.A.B | Should -Be 1
$m.A.C | Should -Be 2
$m.L | Should -Be @(9)
}
}3) Multi-file configuration for environments
Layer defaults under environment-specific files. In PowerShell 7+, ConvertFrom-Json -AsHashtable produces hashtables that our merge function can consume directly.
$envName = $env:ASPNETCORE_ENVIRONMENT ?? 'Production'
$defaults = Get-Content './config.defaults.json' -Raw | ConvertFrom-Json -AsHashtable
$overrides = Get-Content "./config.$envName.json" -Raw | ConvertFrom-Json -AsHashtable
$cfg = Merge-Hashtable -Default $defaults -Override $overrides
$cfg | ConvertTo-Json -Depth 10 | Out-File -Encoding utf8 ./config.effective.jsonIf you are on Windows PowerShell 5.1 (no -AsHashtable), convert to hashtables manually (or upgrade to PowerShell 7+):
function To-Hashtable([object]$obj) {
if ($null -eq $obj) { return $null }
if ($obj -is [System.Collections.IDictionary]) {
$h = [ordered]@{}
foreach ($k in $obj.Keys) { $h[$k] = To-Hashtable $obj[$k] }
return $h
}
if ($obj -is [System.Collections.IEnumerable] -and -not ($obj -is [string])) {
return @($obj | ForEach-Object { To-Hashtable $_ })
}
# PSCustomObject or scalar
if ($obj.PSObject -and $obj.PSObject.Properties) {
$h = [ordered]@{}
foreach ($p in $obj.PSObject.Properties) { $h[$p.Name] = To-Hashtable $p.Value }
return $h
}
return $obj
}
$defaults = To-Hashtable (Get-Content './config.defaults.json' -Raw | ConvertFrom-Json)
$overrides = To-Hashtable (Get-Content "./config.$envName.json" -Raw | ConvertFrom-Json)
$cfg = Merge-Hashtable -Default $defaults -Override $overrides4) DevOps-friendly outputs and reviews
- Stable diffs: Because we use
[ordered],ConvertTo-Jsonproduces stable key ordering. Commitconfig.effective.jsonfor deterministic diffs. - Traceability: In CI/CD, print a redacted preview that excludes secrets before deployment.
- Feature flags: Keep flags in defaults and selectively override per environment for predictable rollouts.
5) Security best practices
- Never log secrets: If merging secrets, either skip serialization or redact sensitive keys before printing.
- Separate sources: Store sensitive overrides in a secret store or vault; only merge them in-memory at runtime.
- Explicit nulling: Use
$nulloverrides to disable default endpoints or credentials intentionally, and add tests to prevent accidental re-enabling.
6) Performance and scale
- Complexity: The algorithm visits each key once per level. For typical app configs (hundreds to thousands of keys), it is fast enough.
- Large arrays: Since arrays replace atomically, the function copies the reference. If you need true immutable copies of large arrays, clone them explicitly when assigning.
- Customizing behavior: If some arrays should merge (e.g., union of tags) while others should replace, add a policy map of key-paths and handle those cases before the default replacement rule.
Putting It All Together
With a dozen lines of PowerShell, you get deep, predictable merges that preserve defaults, avoid nested key loss, and reduce configuration regressions. Arrays replace whole to keep reviews clean and behavior unsurprising. Returning a new object protects callers and makes testing easy.
Adopt this pattern in your scripts, modules, and CI/CD pipelines. You’ll see fewer config-related outages, cleaner diffs, and faster, safer reviews.
Strengthen your configuration patterns in PowerShell. Read the PowerShell Advanced CookBook → https://www.amazon.com/PowerShell-Advanced-Cookbook-scripting-advanced-ebook/dp/B0D5CPP2CQ/