Why an edit costs more, and what the time buys¶
On an edit, yamldoc costs four to seven times what the value libraries do; on a decode, three times. On a parse it is level with goccy and twice yaml.v3, and on a parse-and-write-back with no change it is level with the fastest of the five, because it writes bytes it never re-emitted. This page is the accounting: what each library does per operation, what yamldoc does on top, which guarantee each extra step exists to keep, and where the time goes when it is measured rather than reasoned about. The short version is that the other libraries do one pass and keep one tree; yamldoc keeps everything, checks its own work, and pays for both where it edits.
The numbers¶
Measured on 2026-09-13, Go 1.27.1 on a Ryzen 7 5825U, -cpu 1, every row
from one run of the same harness on the same files, medians of four, at
the working-revision change (D188);
harness and raw output in
the comparison report.
| Operation | yamldoc | goccy | yaml.v3 | yaml.v4 rc | kyaml |
|---|---|---|---|---|---|
| Parse a 43 KB config | 3.6 ms, 2.1 MB, 10k allocs | 3.5 ms, 2.5 MB, 42k | 1.6 ms, 0.5 MB, 9k | 1.5 ms, 0.5 MB, 9k | 1.6 ms, 0.6 MB, 9k |
| 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, 2.0 MB, 8k | 0.9 ms, 0.7 MB, 10k | 0.58 ms, 0.3 MB, 1.7k | 0.56 ms | 0.60 ms |
| Ten keys in one batch | 5.1 ms, 3.1 MB, 10k | 0.9 ms | 0.59 ms | ||
| A hundred keys in one batch | 11 ms, 12 MB, 33k | ||||
Decode to map[string]any |
7.0 ms (Project) |
4.3 ms | 2.0 ms | 2.0 ms |
Three shapes stand out. Editing is where the cost is: one key on a 12 KB file is 4.1 ms against 0.58 ms for yaml.v3 and 0.9 ms for goccy, and a batch grows by about 0.07 ms per command where the other libraries' cost does not move with the number of keys at all. Parsing is not: 3.6 ms against goccy's 3.5 ms, with a quarter of goccy's allocations, and twice yaml.v3, holding four times its memory because every token is kept. Writing back what was read is free: the round trip is 3.5 ms, the parse plus a memcpy, level with the fastest of the others, which each pay for an emit they have to run because they cannot return the bytes they started from.
What the others do¶
Every other Go YAML library, whatever its API, runs the same pipeline that
libyaml established: scan bytes into tokens, turn tokens into events, build
a tree from the events, and either decode the tree into Go values or, for a
round trip, walk the tree and print it. Each stage consumes the one before
it and keeps only what the next stage needs. Whitespace is consumed by the
scanner and gone. A comment is attached to the nearest node or dropped. A
number is converted to a Go type by strconv at the point it is seen and the
text is forgotten. Nothing is re-read; the emitter is trusted to produce
valid YAML because it only ever produces the constructions it knows.
An edit in that model is a decode, a change to a map or a tree node, and an emit. The emit rewrites the whole file, which is why the cost does not depend on how many keys changed, and also why the file comes back reformatted.
That pipeline is fast because it discards, and it produces the results in Should you use yamldoc? because it discards.
What yamldoc does on a parse¶
A parse builds three things instead of one, and the first of them is what the others throw away.
A byte-exact syntax revision. Every token is kept with its location: whitespace, line breaks, comments, indentation, the BOM, the directives, in the file's own encoding. The tree is immutable and every node has an identity that survives across revisions. This is what makes D8 possible: untouched bytes are not preserved by being carefully re-emitted, they are preserved by never being anything other than bytes. It is also most of the memory: a token per whitespace run is a lot of tokens, and 2.1 MB for a 43 KB file is the retained scan, not a leak.
A semantic composition, lazily. The first read that needs meaning
composes the revision: the %YAML directive is negotiated per document
(D126), the schema profile resolves every plain scalar's type under that
version, anchors are bound, << keys are recognised, numbers are parsed as
exact values rather than float64 (D34), and every diagnostic is recorded
with a location and its related locations. This is what makes yes a
boolean in one document and a string in the next, a 30-digit integer exact,
and a duplicate key an error that names both lines. It is done once per
revision and cached.
A comment inventory. Every comment is classified by the ownership rules (D39 to D125): which entry, item, container, document or stream it belongs to. The others attach a comment to a token and move on; yamldoc decides, for every comment, which node's removal takes it with it. This is the rule set that Comment ownership describes, and it is computed, not inferred at the moment of an edit.
The parse benchmark measures the first of these and whatever composition
Bytes needs, which is none. The gap against yaml.v3, 5.6 ms to 1.6 ms, is
the price of keeping the scan.
What yamldoc does on an edit¶
An edit is where the design shows. The pipeline for one Set inside Edit,
per D7, has six steps that run for every command:
- Normalise the input into the closed value model (D6): the Go value becomes an exact graph, with cycles detected and a structured path kept for any fault.
- Update the expectation. A separate semantic model of what the document should mean after this command is derived from the original graph and advanced by each command: which occurrences survive, which bindings must hold, which comments must still be owned by whom. This is the independent statement the commit will be checked against, and it is maintained per command, not reconstructed at the end.
- Plan the patch. The destination's indentation step, flow or block context, key or value role, version, schema and existing style decide the spelling (How a written value is spelt); the result is a byte-range replacement in a patch table.
- Render the working bytes. The patch table is applied to produce the full candidate text.
- Build the working revision. For a scalar command, the target and its ancestors are re-recorded at their new coordinates and everything else is shared with the previous revision, its relocation deferred to a chain that a read applies (D188). A structural command relocates every token and record onto the new buffer instead: not a rescan, but linear in the file.
- Give the working revision its meaning on the next read, so that the next command's selection sees the previous command's result with correct meaning (D7: "later selectors see earlier changes"). For a scalar command on a clean document that is a derivation: the previous graph with one value changed, sharing everything else (D185). Anything else, a structural command or a document with diagnostics, composes from syntax again.
And five that run once per batch, at commit:
- Verify the patch table against the rendered bytes.
- Parse the candidate from scratch. A real scan and parse of the output, exactly as if it had been read from disk. The working revision's planned tokens are never trusted; the grammar has to establish them independently.
- Compose the candidate and compare it with the expectation: every value, binding, and property the expectation says must hold is checked against what the candidate actually means.
- Check comment preservation: every original comment is found in the candidate with the same owner, or the commit fails naming the one that moved.
- Check surviving dependencies (alias targets that must still exist) and publish the revision with a compare-and-swap, so a second writer is an error rather than a lost update.
A batch of ten scalar commands therefore does ten path copies, ten derivations, and one check.
Where the time goes¶
phase_bench_test.go splits one scalar Replace into the command side
(everything up to the commit) and each stage of the commit, on two of the
corpus files, under the default limits. Means of five runs, parsing outside
the timer:
| 43 KB, 1,702 nodes, no comments | 12 KB, 237 nodes, 81 comments | |
|---|---|---|
| The whole edit | 9.0 ms, 6.3 MB, 30k allocs | 2.9 ms, 1.7 MB, 6.4k allocs |
| The command: compose the base, build the expectation, plan, render, path-copy | 2.9 ms (32%) | 0.94 ms (32%) |
| The checked commit | 6.1 ms (68%) | 2.0 ms (68%) |
| ‣ render the candidate bytes | 0.008 ms | 0.003 ms |
| ‣ verify the patch table | 0.041 ms | 0.012 ms |
| ‣ parse and compose the candidate from scratch | 4.8 ms (53%) | 1.1 ms (37%) |
| ‣ compare with the expectation | 0.36 ms (4%) | 0.05 ms (1.6%) |
| ‣ check every comment's owner | 0.24 ms (2.7%) | 0.64 ms (22%) |
| ‣ check surviving dependencies | 0.03 ms | 0.004 ms |
For scale, the parse alone is 3.2 ms and 0.85 ms on the same two files, and the first read that composes semantics adds 1.7 ms and 0.18 ms.
Three things follow. The guard costs a second parse, not a comparison. Comparing the candidate with the expectation is two to four per cent of the edit; what the commit pays for is reading its own output back as if from disk and composing it again, which is the whole point of the guard (D10) and the only part of it that could catch a writer bug the writer shares. The commit is two thirds of a single edit, and it is paid once per batch, so for a hundred commands on the 12 KB file it is 2 ms of 11. What a batch pays per command is now small and mostly bookkeeping: a scalar command re-records the target and its ancestors and shares the rest of the file with the revision before it, deferring their relocation (D188), so the per-command work is the plan, the expectation update and the derived graph, about 0.07 ms on a 12 KB file. The profiles, the per-function attribution and the prototypes that were measured against it are in the performance review.
What the time buys¶
Every step above exists to keep a specific promise. Put against the guarantees on the home page:
| Guarantee | Paid for by | Could be dropped by |
|---|---|---|
| Every untouched byte comes back (D8) | keeping every token; patching bytes rather than emitting a tree | re-emitting, which is what the others do, and the reason they cannot promise it |
| Comments keep their owners | the comment inventory on parse; the preservation check on commit | attaching comments to tokens and hoping the emitter puts them somewhere sensible |
| Nothing commits unchecked (D7, D10) | the independent expectation model per command; the full re-parse, recomposition and comparison at commit | trusting the writer, which is how a bug becomes a corrupted config file with nothing to catch it |
| Values keep their meaning | composing under the document's own version and profile; exact numbers | one fixed rule set and float64, which is where 0777 and 1e3 and yes go wrong |
| Later commands see earlier results (D7, D17) | a working revision and recomposition per command | making commands blind to each other, so the second Set on a key the first created is an error |
| Bounded work on hostile input | a work charge on every step, every limit checked as it is approached | an unbounded allocation |
| A merge, a projection, a validation with locations | provenance carried through composition | discarding locations at decode, which is why the others cannot say where a value came from |
None of those is free, and none of them is accidental. The design chose checked and exact over fast, and a design that had chosen the other way would be one more of the five libraries in the table.
When it matters, and when it does not¶
A configuration file is kilobytes. At 12 KB one edit is 4 ms and a hundred
in a batch are 11 ms; the fsync that follows costs more than the first
and the human who asked for the change will not notice the second. A CLI,
a migration, a config service that writes on change: none of these will
find yamldoc on a profile.
The cases that will: a service that edits a document on every request; a
tool rewriting thousands of keys in one file; anything over a few hundred
kilobytes. For those, measure first, keep batches to what needs to be
atomic, decode values with a value library rather than Project, and set
Options.Limits for the sizes you actually see. The comparison page's
pitfalls say the same in fewer
words.
What could get faster, and what will not¶
The review's findings have landed, and the tables above are measured after them; the ledger is the batch-scaling issue. Against the engine as it was reviewed: parse −37%, parse and compose −44%, a single edit −37% on the 43 KB file and −21% on the 12 KB one, a batch of ten −46%, a hundred −61%, allocations down by half to three quarters, measured on interleaved runs of a before and after binary pair. What did it: a scalar command's working graph is derived rather than recomposed (D185); key-equivalence refinement runs only when a mapping key is a collection, and on demand otherwise; the scanner runs through ASCII plain bodies and whitespace without decoding a rune each, and a printable-ASCII plain token is its own text; a working revision's tokens share one buffer snapshot; a string key is matched on its text before any semantics; a comment-free file skips the ownership tree; and the allocation hygiene described earlier.
The working revision itself went next, under D188: a scalar command now
re-records only the target and its ancestors, shares every other record
and token with the revision before it, and defers their relocation to a
chain the revision carries and a read applies; the expectation model and
the derived graph share their stores in chunks the same way. Against
v0.6.0, a batch of a hundred scalar commands on a 1000-entry synthetic file
is −75% in time and −81% in bytes, and a command's marginal cost there is
0.38 ms (0.07 ms on the 12 KB corpus file in the harness above). It is
not yet sublinear in the file: finding the target's path, the expectation
model's liveness walk and the occasional compaction still touch every node,
and a Get by string key on a large mapping scans it. Projection provenance
was measured and kept: origins cost a quarter of a millisecond on a 43 KB
projection that follows a parse and a composition twelve times that, which
does not buy a signature change (D186).
What will not change is the shape. The commit will always parse the result from scratch and compare it with an expectation built independently of the writer, because that is the guard, and a guard that shares the writer's assumptions is not one (D10). The parse will always keep every token, because a byte it does not keep is a byte it has to re-emit. A version of yamldoc that is as fast as yaml.v3 is yaml.v3.