Should you use yamldoc?¶
Probably, if you are changing a YAML file that a person maintains. Probably not, if you are reading YAML into a struct. This page is the honest version of that answer: what the alternatives are, what each of them does to a real file, measured, and where yamldoc will get in your way.
Two jobs, one word¶
"A YAML library" means two different tools that happen to share a file format.
A value library turns YAML into Go values and back. It is what yaml.Unmarshal
into a struct is for, it is fast, and it is lossy by design: comments, order,
quoting, blank lines and layout have no representation in a map[string]any,
so they cannot survive it. That is fine for data a machine wrote and will read.
An editing library changes one thing in a document and leaves the rest as
the author wrote it. It is what a config set command, a settings screen, a
migration script or a bot that bumps a version needs, and it is where the value
libraries fail, because they were never trying to succeed.
Every other ecosystem keeps the two apart: Rust has toml and toml_edit,
HashiCorp has hclsyntax and hclwrite, Python has tomli and tomlkit.
yamldoc is the editing half for Go and YAML. Use it with a value library,
not instead of one.
When yamldoc is the right tool¶
- A CLI that writes back to its own config file.
myapp config set server.port 9090must not turn a commented, hand-organised file into a sorted dump. OneSet, one line changed. - A settings UI over a file people also edit by hand. Every save is a round trip; a library that drifts a little each time drifts forever.
- A migration or a bot. Bumping a version field across two hundred repositories is only reviewable if the diff is the bump.
- Anything where the file is under review. A change that a reviewer can read is a change that gets merged.
When it is not¶
- Decoding into structs. yamldoc has typed reads and a native projection,
but it is not a struct decoder and will not become one. Use
go.yaml.in/yaml/v4orgoccy/go-yamlfor that, alongside. - Machine-generated YAML nobody reads. If the file is written by your program and read by your program, there is nothing to preserve.
- Kubernetes manifests where the Kubernetes-aware helpers matter.
kyamlknows what ametadata.nameis; yamldoc knows what a mapping key is. - Files that have to parse under a lenient reader. yamldoc's parser is strict to the YAML specification; some files goccy or yaml.v3 accept are not YAML, and yamldoc says so.
The alternatives, and what they do to a file¶
The table is measured, not asserted. Every library that can round-trip a document was run over the eight real project configs in yamldoc's test corpus, first with no edit and then setting each of 70 reachable scalars through the library's own node or path API. The harness, versions and raw output are in the comparison report, measured on 2026-09-13.
| Library | Files back byte-identical | One-key edits that changed one line | Edits that lost a comment |
|---|---|---|---|
| yamldoc | 8 of 8 | 70 of 70 | 0 |
goccy/go-yaml v1.19.2 |
5 of 8 | 24 of 70 (5 refused) | 15 |
go.yaml.in/yaml/v3 v3.0.5, gopkg.in/yaml.v3 v3.0.1, braydonk/yaml v0.9.0 |
4 of 8 | 24 of 70 | 0 |
go.yaml.in/yaml/v4 v4.0.0-rc.6 |
3 of 8 | 16 of 70 | 0 |
kustomize/kyaml v0.21.1 |
2 of 8 | 3 of 70 | 15 |
sigs.k8s.io/yaml v1.6.0 (value-only) |
0 of 8 | 10 of 70 | 54 |
The yaml.v3 family was given SetIndent(2); its default of four spaces would
change nearly every line of every file.
What each one loses, construct by construct, on a no-edit round trip:
| Construct | yamldoc | goccy | yaml.v3 family | yaml.v4 rc | kyaml |
|---|---|---|---|---|---|
| Blank lines between sections | kept | kept | dropped | dropped | dropped |
Inline comment alignment (a: 1 # note) |
kept | collapsed | collapsed | collapsed | collapsed |
| Multi-line flow mapping | kept | reflowed to one line | reflowed | reflowed | reflowed |
| Flow mapping with a comment inside | kept | corrupted: {a: 1, b: 2 # one} |
comment kept, layout changed | as v3 | as v3 |
| Astral-plane characters (emoji) | kept | kept | escaped to \U0001F680 |
escaped | escaped |
Folded scalar > |
kept | kept | body re-flowed | re-flowed | re-flowed |
Merge key <<: *base |
kept | kept | rewritten !!merge << |
kept | rewritten |
| Four-space indentation | kept | kept | re-indented to two | re-indented | re-indented |
| Long plain scalar | kept | kept | kept | wrapped at 80 columns | kept |
| CRLF line endings | kept | LF | LF | LF | LF |
| Missing final newline | kept | added | added | added | added |
%YAML 1.1 directive |
kept | kept | dropped | dropped | dropped |
Comment before --- |
kept | kept | --- dropped |
dropped | dropped |
| Indentless sequence | kept | kept | re-indented | re-indented | kept |
goccy's corrupted cell is the reason yamldoc's first version, which was built on goccy, had to detect that construct and refuse the file. yamldoc's own engine parses it.
Where each library stands¶
gopkg.in/yaml.v3is archived; its author labelled it unmaintained in April 2025. Do not start anything new on it.go.yaml.in/yaml/v3is the YAML organisation's fork of the same code, frozen to security fixes only. Same behaviour, same losses, a maintained import path.go.yaml.in/yaml/v4is where that project's work now happens and is at release candidate 6. ItsNodemodel is the same shape as v3's, and its encoder re-wraps long scalars, which v3's did not. The right value library for new code once it ships.goccy/go-yamlis the best parser and syntax tree of the group and a fine value library. It is not an editor: its emitter reformats what it did not build, its path API cannot reach through an anchor, and one construct comes back corrupted.braydonk/yamlis a yaml.v3 fork that fixed astral-plane escaping and has not been pushed to since February 2025.kustomize/kyamlis built on the yaml.v3 node model for Kubernetes tooling and inherits its losses; it also brings 58 modules into your graph.sigs.k8s.io/yamland the other JSON-shaped wrappers are value libraries. They are in the table to show what a value library does to a file, not because anyone claims otherwise.
What yamldoc guarantees that the others cannot¶
The reason the yamldoc column is all "kept" is structural, not a longer list of special cases. The other libraries parse into a tree and emit the tree; whatever the tree does not model is gone, and the emitter's opinions apply to everything. yamldoc edits the source: the bytes of the edit's footprint are replaced and every other byte is returned untouched, so there is no list of preserved constructs because there is nothing that is not preserved.
On top of that, every transaction re-parses its own output and checks it against an independent statement of what the edit meant, comment ownership included, before it commits. A bug in the writer produces an error and an unchanged file, not a damaged one. What is preserved states the guarantee precisely.
What only yamldoc does¶
Beyond preservation, a few things in the API have no counterpart in the other libraries. The rows that name a specific behaviour of goccy or yaml.v3/v4 were checked against them directly (goccy v1.19.2, go.yaml.in/yaml/v3 v3.0.5, v4 v4.0.0-rc.6); the rest are structural.
| Capability | yamldoc | goccy | yaml.v3 / v4 |
|---|---|---|---|
| A UTF-16 file with a BOM comes back as a UTF-16 file with a BOM | yes | emits replacement characters | transcoded to UTF-8, BOM dropped |
n: 123456789012345678901234567890 reads exactly |
*big.Int, and Rat for decimals |
a string |
a float64 that has lost digits |
| A value's spelling and its type, separately | Raw (0x1F90), LiteralText, TagIdentity (tag:yaml.org,2002:int) and Syntax (kind, style, tag, anchor, location) on every node, under the document's own version |
the AST token's text and position; the type is decided at decode | yaml.Node has Value, Style and a resolved Tag, under one fixed rule set |
8080: and 0x1F90: in one mapping |
Validate reports the duplicate |
last one wins, silently | last one wins, silently |
The schema follows the document's %YAML directive |
yes is a boolean and 0777 is 511 under %YAML 1.1; a string and 777 under %YAML 1.2; each document in a stream decides for itself, from nineteen profiles |
one fixed rule set whatever the directive says: yes a string, 0777 octal, a date a string |
one fixed rule set: yes a string, 0777 octal, a date a time.Time; a %YAML 1.2 document is refused as "incompatible" |
| Keys matched by YAML value | KeyStep(IntKey(8080)) finds 0x1F90: |
string match on the spelling | string match on the spelling |
| Every comment has one owner you can ask for | Entry.Comments, ItemComments, container and document comments, by rule, each with its text, location and owner |
comments attached to the nearest token | head, line and foot comment strings on a node |
| A decoded value knows where it came from | Project returns an Origin per value: line and column of the value and of its key, and the alias hops it arrived through |
the AST has positions; a decoded value has none | yaml.Node has a line; a decoded value has none |
| A merged mapping knows what it inherited | EvaluateMerge lists each entry with the << edge and the anchor that supplied it |
<< applied on decode, its origin gone |
<< applied on decode, its origin gone; in a yaml.Node it is an unapplied key |
| A subtree moves between files without re-marshalling | Detach closes it into a Graph, aliases resolved and values exact, that any write accepts |
via map[string]any or an AST node grafted by hand |
via map[string]any or a yaml.Node grafted by hand |
| Validation of a file you cannot fully resolve | PartialValidation reports every diagnostic it can establish, does not fail on tags it does not know, and lists the checks it could not run |
||
| Inspecting a file that does not compose | Entries, Items, Syntax and the comment views work on a document with a dangling alias or a duplicate key; the diagnostic names both locations |
a duplicate key is a parse error naming both lines, and no tree | a dangling alias is a composer error, and no yaml.Node |
| A batch of edits is atomic | Edit: one failure, nothing committed |
no | no |
| The output is checked before it is committed | re-parsed and compared with an independent statement of the edit, comments and untouched bytes included | no | no |
| Readers keep a consistent view while a write is in progress | immutable Snapshot |
no | no |
| Semantic validation with locations | Validate: dangling aliases, duplicate keys, invalid tagged content, each with a location and related locations |
parse errors only | parse errors only |
| A float you read and write back is a no-op | 0.1 stays 0.1 |
||
| Bounded everything | source bytes, nodes, depth, comments, alias expansion and work per transaction are all limited, so a hostile file fails with an error rather than a large allocation | ||
| Invisible characters escaped on write | bidi overrides and zero-width characters in a written value are escaped | yaml.v3 escapes them on every re-emit, which is the side effect of re-emitting everything | |
| Tags as an extension point | a registry keyed by tag: !secret vault/db resolves on request to whatever your handler returns |
CustomUnmarshaler keyed by Go type |
Unmarshaler on your own Go types |
Tags as an extension point¶
This one deserves more than a row. In every other Go library a custom tag is
a string on a node: you can see !secret and that is where the library's
help ends. In yamldoc a tag is a hook.
var registry yamldoc.ResolverRegistry
_ = registry.RegisterKeyword("secret", func(_ context.Context, in yamldoc.ResolverInput, _ yamldoc.ResolverScope) (any, error) {
path, _ := in.Node.LiteralText()
return vault.Read(path) // your code, your client, your policy
})
resolvers, cleanup, _ := registry.Attach(nil)
defer cleanup()
password, _ := d.Get("db.password") // db.password: !secret vault/database
resolved, _ := resolvers.Resolve(ctx, password, limits)
value, _ := resolved.Payload() // whatever the handler returned
The document is never changed by this: db.password still reads
vault/database, the file still round-trips byte for byte, and an edit to
the sibling key still works. Parsing, validating, editing and emitting never
call a handler; resolution happens only when you ask for a specific node. That
separation is what makes it safe to have secrets, environment lookups, file
includes, template rendering or feature-flag reads behind tags in a file that
tooling also edits.
What the registry gives you, and what it does not:
- Keyed by tag identity. A keyword (
!secret), or a full tag URI, so a%TAG !e! tag:example.com,2000:handle and!e!secretresolve by their expanded identity. Nothing is inferred from an untagged node. - Providers with configuration and cleanup. Register a constructor that
takes its own configuration (a Vault address, a client you already hold),
returns a handler and a cleanup, and is instantiated per
Attach. Two consumers attaching the same registry with different configuration do not see each other's handlers. - Nested resolution. A handler receives a scope it can resolve other
nodes through, in the same source, with cycle detection and a shared
budget;
!includeand!refare a few lines each. - Cancellation, limits, containment. Every request takes a
context, counts calls, depth and work against limits, and a panicking handler becomes aRecoveredFaultwith its stack, never a crash, and its panic value is not formatted into the error, so a secret in flight does not end up in a log. Inside a transaction, a failed resolution fails the transaction. - Provenance. A result carries where it came from: the node's location, its tag, the request and call it was produced in.
- No caching, no lookups of its own, no network. The engine does not know what Vault is. It gives your code the literal node and takes back a value.
There is a real design behind this, twenty decisions of it (spec 0008, D161 to D181), and it is the part of yamldoc that has no equivalent anywhere in the Go YAML ecosystem.
Performance¶
Measured on the same machine and files, Go 1.27.1 on a Ryzen 7 5825U,
-cpu 1, every row from one run on 2026-09-13 at the working-revision change (D188); full output and harness in
the comparison report.
| Operation | yamldoc | goccy | yaml.v3 | yaml.v4 rc | kyaml |
|---|---|---|---|---|---|
| Parse a 43 KB config | 3.6 ms, 2.1 MB | 3.5 ms, 2.5 MB | 1.6 ms, 0.5 MB | 1.5 ms, 0.5 MB | 1.6 ms, 0.6 MB |
| Parse and re-emit it | 3.5 ms | 4.6 ms | 3.7 ms | 3.4 ms | 3.8 ms |
| Parse, change one key, emit (12 KB) | 4.1 ms | 0.9 ms | 0.58 ms | 0.56 ms | 0.60 ms |
| The same with ten keys in one batch | 5.1 ms | 0.9 ms | 0.59 ms | ||
| A hundred keys in one batch | 11 ms | ||||
Decode to map[string]any |
7.0 ms (Project) |
4.3 ms | 2.0 ms | 2.0 ms |
Read it plainly: editing is where yamldoc is slow, four to seven times the others on one key, and linear at about 0.07 ms per edit in a batch on a 12 KB file, because every command builds a new working revision and the commit re-parses the result to check it. That is the price of the checked transaction and the byte-exact edit, paid in full on every call. Parsing is level with goccy and twice yaml.v3; a parse followed by a write-back is level with the fastest row, because the bytes go out as they came in and there is no emit to run.
Where the time goes, stage by stage, and which guarantee each stage is paying for, is worked through in Why an edit costs more, and what the time buys.
Whether it matters depends entirely on what you are doing. A CLI that changes
one setting spends four milliseconds it will never notice. A service that
edits a document on every request, a tool that batch-rewrites thousands of
keys, or anything touching multi-megabyte YAML should measure first, and
should decode values with a value library rather than Project.
The pitfalls¶
Being honest about these is the point of this page.
- It is young. The owned engine ships in v0.6.0. Its parser accepts all 306 valid cases in the official YAML test suite and rejects all 94 invalid ones; 304 match the pinned events exactly, and on the other two, a block scalar whose last line is whitespace at end of input, it gives the same answer as libyaml where the suite's inherited expectation differs (reported upstream). Every edit is checked before it commits. Still, it has one direct consumer in production so far: pin the version and read the changelog.
- It is strict. A flow collection closed at column one under a block key
is invalid YAML (yaml-test-suite
VJP3/00); goccy and yaml.v3 accept it, yamldoc returnsErrSyntax. A file with a dangling alias parses butValidatefails and edits under it are refused. If your users' files are sloppy, you will meet this on day one, and the fix is in the file. - Reads are typed and handles are bound to a revision.
String()on a number is an error, and every command insideEditexpires the node handles from before it; you select again fromtx.Snapshot(). Both are deliberate and both surprise people from themap[string]anyworld. Getting started walks through it. - A dotted path has no quoting.
servers[0].portreaches into a list andports[8080]an integer key, but a key containing a dot or a bracket, or a key of another non-string type, needs aStep. Paths and steps has the grammar and the footguns. - It edits documents; it does not author streams. An empty file can be given a first document. Documents cannot be added, removed or reordered.
- No struct decoding, no merging, no application schema, no file I/O. By design; see Mechanism, not policy. You will write the five lines that read the file and the five that write it.
- Editing is slow, relative to the alternatives. Four to seven times on one key, per the table above, and linear in the number of edits per batch; parsing is level with goccy. Configuration files never notice; hot paths will.
- Resource limits are provisional. The defaults suit configuration
files. A tool that edits multi-megabyte YAML should set
Options.Limitsand measure. - One dependency, but it is ours.
gitlab.com/phpboyscout/go/errorssupplies the sentinel model. There is no YAML parser underneath to fall back on, which is the point and also a thing to know.
The full list, including what is queued, is What yamldoc does not do.
The pairing that works¶
// Decode with a value library, for the program's own use.
var cfg Config
_ = yaml.Unmarshal(src, &cfg) // go.yaml.in/yaml/v4, goccy, whichever you use
// Edit with yamldoc, for the file's sake.
f, _ := yamldoc.Parse(src, yamldoc.Options{})
d, _ := f.Snapshot().Document(0)
_ = d.Set("server.port", 9090)
out, _ := f.Snapshot().Bytes()
The two never touch the same value, so they never disagree about one. That split is what the rest of the industry converged on; yamldoc is what makes it available in Go.