Skip to content

What is preserved, and what is not

The guarantee is stated precisely here, including its limits, because a preservation library that is vague about what it preserves is worse than no library at all.

Preserved

Comments Attached to the same keys. This is the headline guarantee.
Key order Documents come back in the order they were written.
Quoting style "double", 'single' and bare scalars stay as they were.
Block scalars Literal (\|) and folded (>) blocks keep their style.
Anchors and aliases &anchor and *alias are not expanded, and a mutation cannot separate them — see Anchors & aliases.
Merge keys <<: *base stays as written, not rewritten with an explicit tag.
Astral-plane characters Emoji and other 4-byte characters stay literal, not escaped.
Every document Multi-document files keep all documents and separators, and %YAML directives.
Blank lines The blank lines that group a file into sections come back where they were.
Indentation width A two-space file stays two-space; a four-space file stays four-space. Nothing is re-indented.

Blank lines and indentation width are stronger than the guarantee: they survive because the emitter reproduces the source layout of everything it did not build, not because the library tracks them. They hold for untouched content and for in-place scalar edits. They do not extend to content the library synthesises — see the next table.

Not preserved

Inline comment alignment Columns are collapsed to a single space: a: 1 # note comes back a: 1 # note.
Indentation of new nesting A mapping the library creates is indented two spaces per level whatever the file uses, because it comes from the marshaller. A new key beside existing ones is aligned to them.
Layout of new sequences A sequence created by Set is emitted with its - flush against the parent key's column. An existing sequence keeps the indentation it had.
Multi-line flow collections A {} or [] spanning several lines is reflowed onto one line.
Blank lines inside a replaced subtree Replacing a collection replaces everything in it, blank lines and comments included.
CRLF line endings Normalised to LF.
The --- marker Not added to a single-document file that lacked one.
Byte-identity An untouched file is not guaranteed byte-for-byte identical — inline-comment alignment alone breaks it.

A tag on a value that is replaced is dropped, and removing the last key of a nested block loses a comment attached to that block. Both are defects rather than design, and both are recorded in What yamldoc does not do.

Anchors & aliases

An anchor definition and its aliases are one value written in two places, so mutations treat them as inseparable:

  • Replacing an anchored value replaces it under the anchor. Set on a path whose value carries &name keeps the definition attached to the new value, so every *name elsewhere keeps resolving — now to the new value. That is standard YAML semantics, and almost always what a config edit means.
  • Editing inside an anchored mapping works in place. Set("defaults.retries", 5) walks through the &name marker like any other mapping; the anchor, sibling comments and quoting all survive.
  • Assigning over an alias refuses. A *name value is a reference; a write to it cannot say whether the caller means the shared value or this one use of it, so it returns ErrUnsupported — edit the anchor definition instead.
  • Orphaning an alias refuses. Remove of an anchor definition — or a wholesale replacement destroying an anchor defined inside the replaced subtree — while an alias still references it returns ErrUnsupported, naming the alias path that would dangle.
  • A document that arrives broken is reported immediately. An alias to an anchor that does not exist parses, but means nothing; Parse reports it via Unsupported, and emit refuses it.

Never

Bytes that do not parse — or that parse but no longer mean anything. Every emit re-parses its own output and then fully decodes it (resolving aliases) before returning. Parsing proves shape; decoding proves meaning — a dangling alias is invisible to the parser and only surfaces when aliases are resolved. If either check fails, you get an error, not a damaged document. This is not a best-effort promise — it is a hard invariant, and it is tested by fuzzing the mutation paths.

What the invariant covers is structure and resolvability, and it is worth being exact about the gap that leaves. Output that parses and decodes can still say something other than what you assigned: a string written over a plain scalar is not re-quoted, so hello # world emits as text plus a comment and passes both checks. See Assigning a string that needs quoting.

Why byte-identity is out of scope

Byte-identity would require splicing — patching only the changed bytes of the original, rather than re-emitting the document from its parsed form.

That needs each node's byte range, and no Go YAML library exposes one. The closest available is a rune offset that additionally drifts by one for every preceding comment, so a splicing implementation would have to build and maintain its own line index, walk block-scalar spans by hand, and solve insertion separately.

That is a substantial amount of fragile machinery, and re-emission already gets most of the way there for free: blank lines and indentation width come back because the emitter reproduces the source layout of everything it did not build. What splicing would buy on top is inline-comment alignment, flow collections kept across the lines they were written on, and a promise rather than an observation. The judgement made here is that comment survival is what people actually care about, and that residual formatting normalisation is a fair price. If your use case genuinely needs byte-identity, this is the wrong library and it is better to know that early.

Stability across repeated edits

Emission converges immediately: writing a document twice produces the same bytes both times. A tool that saves on every keystroke will not accumulate drift, and a file under version control settles after its first write rather than producing noise on each save.