Skip to content

yamldoc

Purpose

A Go library for making targeted edits to a YAML document while preserving comments, key order, quoting and block styles.

Parse bytes → locate a node by path → set or remove it → re-emit bytes. Everything the caller did not touch comes back semantically unchanged, with its comments still attached to the right keys.

Why it exists

Nothing in Go does this. The ecosystem was surveyed and measured (appendix); the gap is real and specific.

Candidate Why it does not close the gap
github.com/goccy/go-yaml The best available parser/CST — comments, positions, styles — but not an editor. Mutation, comment ownership and several corruption cases are left to the caller. This library is built on it.
gopkg.in/yaml.v3 Archived 2025-04-01. Mangles content: merge keys rewritten !!merge, folded scalars reflowed, astral-plane characters escaped.
go.yaml.in/yaml/v3 The maintained fork, declared frozen legacy — security fixes only. Same content mangling.
go.yaml.in/yaml/v4 Release-candidate only. Fixes merge keys, still escapes astral-plane characters unconditionally — and that escaping cascades, rewriting a literal block containing an emoji as a double-quoted string.
github.com/braydonk/yaml A fork built for this purpose. Measurably worse than yaml.v3 at preservation, dormant since Feb 2025, and abandoned as a dependency by google/yamlfmt, its own primary consumer.
sigs.k8s.io/kustomize/kyaml Requires spf13/cobra; re-encodes, losing blank lines and alignment.
pelletier/go-toml/v2 (for comparison) The maintainer explicitly descoped document editing: "document manipulation is out of scope for v2."

The wider pattern: every ecosystem splits format-preserving editing from value decoding into separate libraries — Rust toml_edit/toml, HashiCorp hclwrite/hclsyntax, Python tomlkit/tomli. Go has the value half many times over and the editing half not at all.

Scope — mechanism, not policy

This is the boundary that keeps the library general, and it is not negotiable.

In scope. Parsing a YAML file into documents; locating nodes by path; setting and removing values; comment ownership during mutation; re-emitting; access to multi-document files; detecting constructs that cannot be safely round-tripped.

Out of scope. Merging. Precedence. Overlay semantics between documents. Schema or validation. Filesystem access. Any opinion about what a document means.

The distinction in practice: this library will tell a caller that a file contains three documents and let them edit any of them. It will not decide that later documents override earlier ones — that is a configuration semantic, not a YAML one, and belongs to the consumer.

Decisions

D1 — Byte-in, byte-out. No filesystem.

The API operates on []byte. It does not open, read, write, watch or lock files.

Rationale: file I/O is where policy lives — atomicity, permissions, temp-and-rename, watching, locking, filesystem abstraction. Consumers have opinions about all of it, and the first consumer (go/config) owns file I/O deliberately as a single-owner design. Keeping this library pure makes it trivially testable, usable over any storage, and free of an afero dependency it would only be passing through.

D2 — Substrate is github.com/goccy/go-yaml

Measured best of five candidates: preserves comments, key order, quoting styles, block scalars, anchors and aliases, and — decisively — does not alter content. Actively maintained, and ~0.5 MB of binary (CUE 14.4 MB, HCL 4.2 MB for comparison).

The failures that eliminated the alternatives are content changes, not formatting: merge keys rewritten, folded scalars reflowed, astral-plane characters escaped. Anyone re-evaluating this must score on content preservation and comment attachment, not blank-line retention.

D3 — The guarantee, and its limits, are stated precisely

Guaranteed. The document's data structure is preserved. Comments survive, attached to the same nodes. Key order, quoting style, block scalars (|, >), anchors, aliases and merge keys are preserved. Untouched nodes are semantically unchanged.

Not guaranteed. Blank lines, blank-line grouping, indentation width, inline-comment column alignment, the --- document-start marker on a single-document file, byte-identity.

Byte-identity is achievable only by splicing the original bytes rather than re-emitting, which requires node byte-ranges no Go YAML library exposes. It is out of scope; see Rejected alternatives.

D4 — Scalar assignment is type-aware

goccy renders scalar types from different places, and this is not documented:

Node type Rendered from
StringNode n.Value (the node field); quoting style from n.Token.Type
IntegerNode, FloatNode, BoolNode n.Token.Value

Mutating only the token — the obvious approach, and the advice found in circulation — silently no-ops for strings, which are the most common scalar in configuration. Measured: one setter changed enabled: falsetrue correctly while leaving log.level and content.profile untouched, with no error. A test suite exercising booleans would pass while string writes did nothing.

Assignment MUST dispatch on concrete node type. Quoting style is preserved automatically, because StringNode takes its style from the token.

D5 — Comment ownership on removal is explicit and tested

Removing a node must not take a neighbouring comment with it. Five rules, of which goccy gives three natively and two must be implemented:

Rule Native
Head comment directly above a key → removed with it free
Head comment separated by a blank line = section comment → survives must implement
Inline comment on the key's own line → removed with it free
Trailing comment after the last key → hoisted to the preceding sibling must implement
No bleed onto the following key's head comment free

Reproduced — removing port, unrelated to the comment, destroys it:

# input                                  # goccy native output
server:                                  server:
  host: localhost                          host: localhost
  port: 8080                             database:
  # end of the server block — applies      host: db.internal
  #   to the whole section
database:
  host: db.internal

Distinguishing "directly above" from "after a blank line" requires inspecting source positions; both parse to the same attachment.

Corollary for insertion: appending a key to a mapping makes it absorb any trailing comment block as its head comment. Sometimes ideal, sometimes theft — same positional analysis, implement together.

D6 — An empty container is a value, not an absence

key: {} and key: [] are present values, distinct from key being absent. Removing the last entry of a container leaves it empty; it does not remove the parent.

This is also a correctness requirement, not only a semantic one. Measured: removing the only key of a nested mapping makes goccy emit the empty map at column 0, producing YAML that fails to parse:

feature:
{}            ← column 0 — "unexpected map key"
other: 1

Removing one of several keys is fine; removing the only root key is fine. Only nested-becomes-empty corrupts. The library MUST emit feature: {} on the key's own line.

D7 — A file is an ordered list of documents. No overlay semantics.

Multi-document files are fully supported: every document is parsed, editable and re-emitted, with separators and per-document comments preserved. Measured — editing inside one document leaves every other document, all separators and all comments untouched.

The library assigns no meaning to document order. YAML defines a multi-document stream as a sequence of independent documents. A consumer may choose to treat later documents as overriding earlier ones; that is the consumer's semantic and must not leak in here.

Creating or removing whole documents is out of scope for the first cut.

D8 — Unsafe constructs are detected and reported, never silently mangled

Some constructs cannot be round-tripped safely. The library MUST detect and report them so the caller can decide; it MUST NOT silently produce a damaged document.

Known case: a multi-line flow collection containing interior comments. goccy does not merely lose the comment — it swallows the closing delimiter into it, producing invalid YAML:

# input                     # output — INVALID
bounds: {                   bounds: {min: 1, max: 10 # lower bound}
  min: 1,  # lower bound
  max: 10  # upper bound
}

Detection belongs here; the decision — refuse, warn, or proceed — belongs to the caller. This is the mechanism/policy line in miniature.

D9 — The library never emits invalid YAML

Every mutation path re-parses its own output before returning it, and returns an error rather than damaged bytes. This is cheap, and it converts a class of silent corruption into a returned error.

Two of the defects above (D6, D8) were found precisely because output was re-parsed rather than eyeballed. The guard stays permanently.

D10 — API shape

Small, explicit, and free of configuration vocabulary.

// Parse a YAML stream. Reports constructs that cannot be safely round-tripped.
func Parse(src []byte) (*File, error)

type File struct{ /* … */ }
func (f *File) Documents() []*Document
func (f *File) Unsupported() []Unsupported   // detected, not judged (D8)
func (f *File) Bytes() ([]byte, error)       // re-emit; validates (D9)

type Document struct{ /* … */ }
func (d *Document) Get(path string) (Node, bool)
func (d *Document) Set(path string, value any) error   // type-aware (D4)
func (d *Document) Remove(path string) error           // comment rules (D5), empty rule (D6)

// Comment access, for callers that need to reason about attachment.
type Node interface {
    Path() string
    Comments() Comments   // head, line, foot
    Position() Position   // line, column
}

Paths are dot-separated. Sequence indexing and insertion positioning are deferred until a second consumer justifies them.

D11 — Dependency discipline

goccy/go-yaml and the standard library. Nothing else in the production graph.

A footprint test enforces it, in the spirit of the first consumer's own: the point of a focused library is that depending on it costs almost nothing.

D12 — Test-first, with adversarial fixtures mandatory

Implementation is test-first: a behaviour without a failing test that describes it is not ready to be built.

Adversarial fixtures are required alongside realistic ones. A corpus of eight real project config files validated the common path completely — and missed both corruption cases in this spec. D6 and D8 were found only by constructing the case deliberately. Realistic fixtures prove it works; adversarial fixtures find where it breaks; both are mandatory.

Run under -race.

Acceptance criteria

  1. A no-op parse and re-emit preserves every comment, attached to the same node — verified against a corpus of real, hand-authored YAML.
  2. Re-emission converges: a second emit of unchanged content produces no further drift.
  3. Setting one scalar changes exactly one line relative to the re-emitted baseline; the node's inline comment and quoting style survive.
  4. Setting a string value takes effect (guards D4's silent no-op).
  5. Merge keys, folded and literal block scalars, anchor/alias pairs and astral-plane characters survive a write unmangled.
  6. Removal obeys all five rules in D5, including hoisting a trailing comment rather than destroying it, and no bleed onto the following key.
  7. Removing the last entry of a nested container yields key: {} / key: [] — valid YAML, parent retained.
  8. Inserting a key attaches at the deepest existing ancestor without disturbing sibling comments or order.
  9. Every document of a multi-document file is readable and editable; editing one leaves the others, all separators and all comments untouched.
  10. A multi-line flow collection with interior comments is reported via Unsupported(), and any attempt to emit a document containing one fails with an error rather than producing invalid YAML.
  11. No API path can return bytes that fail to re-parse.
  12. The production dependency graph contains only goccy/go-yaml.

Non-goals

  • Merging, precedence, or overlay semantics between documents or files. Consumer policy.
  • Schema, validation, or type coercion. This library moves text, not meaning.
  • Filesystem access (D1).
  • Byte-identical round trips. See Rejected alternatives.
  • A general YAML query language. Dot paths cover targeted edits; JSONPath-style querying is a different product.
  • Creating or removing whole documents in a multi-document file, in the first cut.
  • Comment authoring APIs (attach a new comment to a new key) until a consumer needs them.

Rejected alternatives

Byte-span splicing — patching only the changed bytes, which would deliver true byte-identity. No Go YAML library exposes a node end position, and goccy's Position.Offset is a 1-based rune offset that additionally drifts −1 per preceding comment. A splice implementation must therefore build and maintain its own line index, plus handle block-scalar span walking and much harder insertion. Disproportionate for a guarantee (blank lines, alignment) nobody has asked for.

Re-emitting via yaml.v3/v4 — content mangling, see D2.

Wrapping kyaml — pulls in cobra, and re-encodes anyway.

Owning file I/O — see D1. It would import consumer policy into a library whose value is being policy-free.

Appendix — measured findings

All measured directly; each justifies a decision above.

Finding Decision
Round-trip over 8 real hand-authored config files: every comment preserved and attached to the same key — 81/81 raw comment references and 111/111 keys on the largest (232 lines, 60 comment lines) D2, D3
Idempotency: converges at the first emit; no drift or accretion D3
Targeted set on 4 real operations: exactly 1 line changed vs the re-emitted baseline; inline comments and quote style preserved D3
goccy renders StringNode from n.Value but numeric/bool nodes from n.Token.Value; mutating the token alone silently no-ops for strings D4
Path.ReplaceWithNode destroys the target node's inline comment; in-place mutation does not D4
A freshly-parsed node appended to a mapping lands at column 0 unless a sibling's Position.Column/IndentNum/IndentLevel is copied D5
Removing the last key of a nested mapping emits {} at column 0 — output fails to parse. Removing one of several, or the only root key, is fine D6
Multi-document: documents and per-document comments preserved, leading --- retained, and a --- inside a block scalar correctly not treated as a separator D7
Multi-document write: editing document 0 or 1 of a 3-document file left the others byte-untouched, separators intact, 6/6 comments retained D7
Multi-line flow collections with interior comments swallow the closing delimiter into the comment; output rejected by both goccy and yaml/v3 D8, D9
Comments around flow nodes, single-line flow mappings with anchors, single-line flow sequences, and edits adjacent to flow nodes all preserve comments and anchors D8 scope
yaml.v3 turns 99999999999999999999 into float64(1e+20) irrecoverably (threshold is uint64 overflow, ~1.8e19) D2
yaml/v4 rc.6 escapes astral-plane characters unconditionally (WithUnicode governs BMP only), and the escaping cascades — a literal block containing an emoji is rewritten as a double-quoted string D2
goccy ~0.5 MB added binary; CUE 14.4 MB; HCL 4.2 MB D2, D11

Known untested: goccy carries open comment-fidelity issues beyond those measured here (#821, #820, #870). D8's detect-and-report mechanism is the general defence; extend its detection as further constructs are found.

First consumer

gitlab.com/phpboyscout/go/config — a configuration container whose Store owns all config I/O and needs comment-safe, layer-correct writes to hand-authored config files. Its spec (2026-07-19-store-architecture.md) holds the configuration policy this library deliberately excludes: merge order, documents-as-overlay semantics, write routing, provenance, validation.

That separation is the test of this library's design. If a decision here can only be justified by reference to configuration, it belongs in the consumer instead.