Skip to content

What yamldoc does not do

A preservation library that is vague about its edges is worse than none, because you find the edge in production. This page is the honest list: things deliberately out of scope, things the design cannot reach, and defects that are known and currently unfixed.

The companion page, What is preserved, covers formatting. This one covers capability.

Deliberately absent

These are not gaps to be filled later. They are consequences of the library moving text and assigning it no meaning — the reasoning is in Mechanism, not policy.

Not provided Why What to use instead
File I/O Atomic writes, permissions, locking and watching are policy, and callers disagree about all of them os, afero, your own writer. Parse takes bytes; Bytes returns them
Merging, precedence, overlays Which of two documents wins is an application's semantic, not YAML's Decide it yourself over Documents()
Schema or validation Whether a value is acceptable depends entirely on the application Decode into a struct with your usual YAML library
Decoding into Go types This is the editing half of the split; the value half already exists goccy/go-yaml, go.yaml.in/yaml
Creating or deleting documents in a stream The set of documents is fixed by the source Concatenate the bytes yourself
Formatting or pretty-printing Reformatting is exactly what this library exists to avoid yamlfmt, if you want a formatter

Cannot be addressed, so cannot be edited

The path grammar is a dotted list of literal mapping keys, and that ceiling is lower than most people assume. The full account is in Path syntax; the consequences worth knowing before you design around the library:

  • No sequence element can be reached. There is no index syntax. To change one item in a list you read the list, rebuild it in Go and set it back — which loses the comments and styles inside that list, because a Go value does not carry them.
  • A key containing a dot cannot be reached at all. log.level: debug at the top level is visible from Keys and addressable by nothing.
  • Nothing below an alias can be reached. Walk to the anchor definition instead.

An empty file cannot be filled in

Parse accepts empty input, and a comments-only file, and returns a document that has no root mapping. Every path operation on it then fails with ErrNotFound:

f, _ := yamldoc.Parse(nil)
err := f.Documents()[0].Set("server.port", 8080)
// yamldoc: path not found: document root is <nil>, not a mapping

The same applies to a document whose root is a sequence or a bare scalar. This library edits an existing mapping document; it does not author one. If you need to create a file from nothing, marshal the initial content with an ordinary YAML library and hand the bytes to Parse afterwards.

A *File is not safe for concurrent use

Set and Remove mutate a tree that every *Document from the same *File shares, and Bytes reads all of it. Nothing in the library locks. Two goroutines editing the same file will race, and Documents() returning a copied slice does not change that — the documents inside it are the same objects.

Confine a *File to one goroutine, or guard it with your own mutex. Parsing the same bytes twice to get two independent files is also fine, and cheap.

Known defects

These are real, reproducible and currently unfixed. They are here so you can avoid them, not because they are intended.

Assigning a string that needs quoting

Set mutates an existing scalar in place to keep its comment and quoting style. When the target is a plain, unquoted scalar and the new value is a string that YAML would need quoted, the text is written literally and no quotes are added.

// given "greeting: plain"
doc.Set("greeting", "hello # world")
greeting: hello # world

That output is valid YAML — it just means something else. Re-read, the value is hello and the rest is a comment. The emit-time guard does not catch it, because the document parses and decodes perfectly well.

The failures split into two kinds, and the quiet kind is the one to plan for.

Silently wrong — valid YAML, different meaning:

Value passed Emitted Decodes as
"hello # world" greeting: hello # world hello
"" greeting: null
" leading" greeting: leading leading
"123" greeting: 123 the number 123
"null" greeting: null null

Loudly refused — the emit guard catches these, Bytes returns ErrEmitInvalid and no bytes:

"key: value", "- item", "? x", "*alias", "&anchor", "@x", "%x", "|x", ">x".

Two things bound the blast radius. A target that is already quoted is re-quoted correctly, escaping and all — 'it''s', "hello # world". And a newly created key goes through the marshaller, which quotes properly. It is specifically overwriting a plain scalar that is unsafe.

Until it is fixed, vet strings you did not author: reject or quote a value that is empty, has leading or trailing whitespace, contains #, or would read as a number, boolean or null. If you cannot vet it, replace the parent mapping wholesale so the value goes through the marshaller instead.

A multi-line string over a non-string scalar

Assigning a string containing newlines works when the target is already a string — it becomes a literal block. Over an integer, float or boolean target the newlines are written raw:

// given "retries: 1"
doc.Set("retries", "one\ntwo")
out, err := f.Bytes()
// err: yamldoc: refusing to emit invalid YAML: [2:1] non-map value is specified

Nothing is damaged — the emit guard catches this one — but the error arrives as ErrEmitInvalid, whose own documentation says it indicates a library bug. Here it does.

A comment on a block that loses its last key is lost

The comment ownership rules promise that a comment describing a block survives the removal of a key inside it, re-homed onto a neighbour. When the removed key is the block's only key there is no neighbour, and the mapping collapses to {} — taking the comment with it.

a:
  b: 1
  # describes the block
c: 2
doc.Remove("a.b")
a: {}
c: 2

The comment is gone. The same happens to a head comment above the only key. At the top level the comment does survive ({} # describes the block), because the collapse is only applied to nested mappings — so whether your comment lives depends on its depth, which is not a rule anyone would design.

If comments in that position matter to you, read them out with Node.Comments before removing the key.

A bracketed path silently creates a nonsense key

servers[0].port is not an indexed path. It is a path whose first segment is the literal key servers[0], and Set will happily create one:

servers:
  - name: alpha
servers[0]:
  port: 9090

No error, and a config file with a key nobody will ever read. If paths come from users, reject bracket syntax before passing it on.

A tag is dropped when its value is replaced

A tagged value such as a: !!str 5 round-trips untouched. Assigning over it drops the tag:

doc.Set("a", "6")   // a: !!str 5  →  a: "6"

Reading it back is also unusual: Node.String returns !!str 5, tag included, rather than the value alone.

What is not a limitation, despite appearances

Worth stating, because each of these gets reported as a bug.

  • feature: {} after removing the last key. Deliberate. An empty mapping and an absent key are different states, and code may require the parent to exist. Remove the parent if you want it gone.
  • Remove of an absent key returning an error. Deliberate. Silence there hides a typo in a key name.
  • Refusing to write through an alias. Deliberate. The library cannot tell whether you mean the shared value or this one use of it.
  • Inline comments losing their column alignment. Known and accepted; the reasoning is under Why byte-identity is out of scope.