---
title: Merge semantics reference
description: Exactly what load_merged_yaml and load_merged_json do to each kind of value — what merges, what replaces, what deletes, and what YAML loses on the way through.
tags: [reference, yaml, json, merge]
---
# 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](https://www.rfc-editor.org/rfc/rfc7396): 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:
| 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:
```text
name: classic
palette:
primary: blue
accent: orange
```
`user.yaml`, on the higher layer:
```text
palette:
accent: pink
```
What `load_merged_yaml("theme.yaml")` deserialises:
```text
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:
```text
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:
```text
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:
| `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:
```text
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:
```text
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.