Merge semantics reference¶
load_merged_yaml and load_merged_json fold a file across every layer
that provides it. The fold is RFC 7396 JSON Merge
Patch: each layer is applied,
in registration order, as a patch over everything below it.
RFC 7396 is a small specification with two rules that catch people out.
Both are set out below, with the behaviour verified against
rtb-assets 0.6.3.
What happens to each kind of value?¶
Given a lower layer and a higher layer that both provide the file:
| In the higher layer | Result |
|---|---|
| A key the lower layer does not have | added |
| A scalar (string, number, bool) over a scalar | replaces |
| A map over a map | merged key by key, recursively |
| A map over a scalar | replaces |
| A scalar over a map | replaces — the whole subtree is discarded |
| An array over anything | replaces wholesale — arrays are never element-merged |
null over anything |
deletes the key — see below |
| A whole document that is not a map | replaces everything merged so far |
defaults.yaml, on the lower layer:
user.yaml, on the higher layer:
What load_merged_yaml("theme.yaml") deserialises:
Why does setting a key to null remove it?¶
Because RFC 7396 defines null in a patch as "delete this member". A
higher layer cannot use it to set a value to null:
If your T has a required b, that override turns into a
deserialisation failure rather than an override. There is no escape
syntax and no opt-out; the merge is RFC 7396 or nothing.
Every YAML spelling of null does it, including the ones people write by
accident: b: null, b: Null, b: ~, and a bare b: with nothing
after the colon. An empty string (b: "") is a value, not a null, and
overrides normally.
Design your config so absence and null mean the same thing. Give
optional fields Option<T> and #[serde(default)], and let a user
remove a key rather than null it. If you genuinely need a tri-state —
unset, explicitly null, set — encode it as a value the format can carry
(mode: inherit), not as null.
Why did an empty override file wipe my configuration?¶
Because an empty YAML document parses to null, and RFC 7396 applies a
non-map patch by replacing the target wholesale. A higher layer
containing nothing — an empty file, or a file with only comments —
therefore discards everything the layers below contributed:
Deserialising null into a struct then fails with a message about an
invalid type, which points at the wrong file. Two ways to avoid it:
- Ship an override template containing
{}rather than an empty file. An empty map is a valid no-op patch. - Do not create the override file at all until there is something to put in it. A layer that does not have the path is skipped entirely.
An empty file in the lowest layer is harmless: it becomes the starting value and the next layer's map replaces it.
What does YAML lose on the way through?¶
YAML is parsed by serde_yaml, converted to a serde_json::Value, and
merged there. The conversion is lossy in ways that matter for config:
| YAML input | Value after conversion |
|---|---|
x: .inf, x: -.inf, x: .nan |
null — silently |
when: 2026-01-02 |
the string "2026-01-02" |
1: one (non-string key) |
the string key "1" |
use: *anchor |
the anchor's value, expanded in place |
yes / no / on / off |
the strings "yes", "no", "on", "off" — not booleans |
!!binary and other explicit tags |
not round-tripped; treat as unsupported |
The yes/no row catches people migrating YAML 1.1 config: write
true and false. The .inf/.nan row is the dangerous one, because
nothing reports it — a duration or threshold written as .inf arrives
as null and then fails deserialisation, or silently takes a default.
JSON files skip this conversion entirely and merge as parsed.
Which layer is blamed when parsing fails?¶
A parse failure on any contributing layer aborts the load — there is no fallback to the layers below, because a silent fallback hides the broken file. The error names the layer by its registration label:
The final step — fitting the merged document into T — happens after
merging and so cannot attribute a layer. Its error carries the bare
path:
If you see the second form, the individual files are all well-formed and it is their combination that does not fit the type.
What is not merged¶
- Blob reads.
open,open_textandexiststake the winning layer's bytes and stop; only these two methods fold across layers. - TOML, INI, XML, and every other format. Only YAML and JSON have
loaders. A TOML file can still be carried as an asset and read with
open_text, then parsed by the caller — it just gets last-wins shadowing rather than a merge. - The same document under two different names. Merging keys on
path, soconfig.yamlandconfig.ymlare two unrelated assets.