Skip to content

Getting started: change a config file without wrecking it

By the end of this you'll have a small Go program that opens a hand-written YAML config file, changes a port, adds a key, deletes another, and writes the file back with every comment still where its author put it.

Allow about fifteen minutes.

Before you start

You'll need Go 1.26 or later — go version will tell you. Everything else you need arrives with one go get.

Work in a scratch directory. The last step overwrites the file you create, so don't point it at a config you care about until you've seen it work.

mkdir yamldoc-tour && cd yamldoc-tour
go mod init example.com/tour
go get gitlab.com/phpboyscout/go/yamldoc

That pulls in one dependency, goccy/go-yaml. Nothing else.

Create a file worth protecting

Save this as config.yaml. It has the things a re-marshal would destroy: a header, an inline comment, a trailing comment on a block, and blank lines that group it.

# Service configuration.
# Edited by hand; keep the comments.

server:
  host: localhost   # the interface we bind to
  port: 8080
  # end of the server block

logging:
  level: info
  debug: true

Read the file and look around

yamldoc works on bytes and never touches the filesystem, so you do the reading and writing. Start with a program that only looks:

package main

import (
    "fmt"
    "os"

    "gitlab.com/phpboyscout/go/yamldoc"
)

func main() {
    src, err := os.ReadFile("config.yaml")
    if err != nil {
        panic(err)
    }

    f, err := yamldoc.Parse(src)
    if err != nil {
        panic(err)
    }

    doc := f.Documents()[0]

    keys, _ := doc.Keys("")
    fmt.Println("top-level keys:", keys)

    n, ok := doc.Get("server.port")
    fmt.Println("found:", ok)
    fmt.Println("value:", n.String())
    fmt.Println("line:", n.Position().Line)

    host, _ := doc.Get("server.host")
    fmt.Printf("host comment: %q\n", host.Comments().Line)
}
go run .
top-level keys: [server logging]
found: true
value: 8080
line: 6
host comment: "the interface we bind to"

Paths are dotted mapping keys — server.port walks from the root to server and then to port. There's no index syntax, so nothing inside a YAML list can be addressed this way; if your file has lists, read Path syntax before you go much further.

Check the document is safe to edit

A few YAML constructs can't be re-emitted without damage. Parse finds them and reports them, and leaves the decision to you. Check before you make any changes — failing after the user has edited three fields looks arbitrary.

Add this straight after Parse:

if u := f.Unsupported(); len(u) > 0 {
    fmt.Println("cannot edit safely:", u[0])
    os.Exit(1)
}

Your file is clean, so nothing prints. To see it fire, temporarily add a multi-line flow collection with a comment inside it:

bounds: {
  min: 1,   # lower bound
  max: 10
}
cannot edit safely: line 12: multi-line flow collection with interior comments (collection spans lines 12-15)

Take that back out before continuing. The full catalogue is in Unsupported constructs.

Change a value

if err := doc.Set("server.port", 9090); err != nil {
    panic(err)
}

An existing scalar is mutated where it sits, so its inline comment and its quoting style stay with it. Always check the error — Set refuses several things rather than guessing, and a silent failure here means a config change that never happened.

Add a key that isn't there yet

if err := doc.Set("server.tls.cert", "/etc/ssl/service.pem"); err != nil {
    panic(err)
}

Setting a path that doesn't exist creates it, and missing intermediate mappings are created on the way — tls appears so that cert has somewhere to live. New keys are appended to the end of their parent rather than inserted, which is what keeps the existing keys' order and comments untouched. It does mean a new key can land after a comment that closes the block; you'll see that in the output below.

One thing Set won't do is create a path that runs through a scalar:

err := doc.Set("logging.level.verbose", true)
// yamldoc: path not found: "logging.level" is not a mapping, so the remaining
// path cannot be created

Creating it would mean deleting logging.level, which you didn't ask for.

Remove a key and watch the comments

if err := doc.Remove("logging.debug"); err != nil {
    panic(err)
}

Removing a key takes the comments that describe it and leaves the ones that describe something else — a comment directly above the key goes with it, a section heading separated by a blank line stays, a trailing comment is re-homed onto the key above. That's the part a naive editor gets wrong; the rules are in Comment ownership.

Removing something that isn't there is an error rather than a no-op:

err := doc.Remove("server.nope")
// yamldoc: path not found: server.nope

That's deliberate — silence there hides a typo in a key name.

Write it back

out, err := f.Bytes()
if err != nil {
    panic(err)
}

if err := os.WriteFile("config.yaml", out, 0o644); err != nil {
    panic(err)
}

Bytes re-parses and fully decodes its own output before handing it to you. If anything went wrong you get an error and no bytes, so a structurally broken document can't reach the file.

Run it, then look at config.yaml:

# Service configuration.
# Edited by hand; keep the comments.

server:
  host: localhost # the interface we bind to
  port: 9090
  # end of the server block
  tls:
    cert: /etc/ssl/service.pem

logging:
  level: info

Both comments survived, the blank line between the sections survived, debug is gone and port changed. Two things did move: the inline comment lost its column padding — alignment collapses to a single space, and that is not preserved — and tls landed after the block's closing comment, because new keys append.

Feed that output straight back through Parse and Bytes and you get the same bytes: emission converges immediately, so a tool that saves on every keystroke doesn't accumulate drift. Re-running this program is not the way to check that, though — the second run panics on Remove("logging.debug"), because debug is already gone and removing an absent key is an error rather than a no-op.

One thing to know before you use this on real input

If the value you assign comes from a user rather than from your own code, read Assigning a string that needs quoting first. Overwriting a plain scalar with a string that YAML would need quoted — one containing #, or a leading space, or the text 123 — currently writes it unquoted, and the file then means something different. Values you control are fine; values you don't need vetting.

Where to go next