Skip to content

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:

name: classic
palette:
  primary: blue
  accent: orange

user.yaml, on the higher layer:

palette:
  accent: pink

What load_merged_yaml("theme.yaml") deserialises:

name: classic
palette:
  primary: blue
  accent: pink

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:

lower layer:   a: 1
               b: 2

higher layer:  b: null

merged:        a: 1          <- b is gone entirely, not set 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:

lower layer:   a full, valid config
higher layer:  a file containing only "# nothing yet"

merged:        null

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 nullsilently
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:

failed to parse asset `cfg.yaml (layer `user`)` as YAML: ...

The final step — fitting the merged document into T — happens after merging and so cannot attribute a layer. Its error carries the bare path:

failed to parse asset `cfg.yaml` as YAML: invalid value: integer `99999`, expected u16

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_text and exists take 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, so config.yaml and config.yml are two unrelated assets.