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: false → true 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:
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¶
- A no-op parse and re-emit preserves every comment, attached to the same node — verified against a corpus of real, hand-authored YAML.
- Re-emission converges: a second emit of unchanged content produces no further drift.
- Setting one scalar changes exactly one line relative to the re-emitted baseline; the node's inline comment and quoting style survive.
- Setting a string value takes effect (guards D4's silent no-op).
- Merge keys, folded and literal block scalars, anchor/alias pairs and astral-plane characters survive a write unmangled.
- Removal obeys all five rules in D5, including hoisting a trailing comment rather than destroying it, and no bleed onto the following key.
- Removing the last entry of a nested container yields
key: {}/key: []— valid YAML, parent retained. - Inserting a key attaches at the deepest existing ancestor without disturbing sibling comments or order.
- Every document of a multi-document file is readable and editable; editing one leaves the others, all separators and all comments untouched.
- 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. - No API path can return bytes that fail to re-parse.
- 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.