To read a YAML file in Go without a struct, unmarshal it into a map[string]any and use type assertions to reach the values. That is enough for a few top-level keys. For a value deep in the file, read it by path into a typed variable. For keys you don’t know in advance, walk the document node by node. This page shows all three with oneyaml.dev/go v1.0.0, then how to change a value and write the file back with its comments, and what yaml.v2 and yaml.v3 return for the same untyped read.
Every example reads this file:
# Service settings server: host: 0.0.0.0 port: 8080 tls: true database: url: postgres://db.internal:5432/app pool: 10 features: - search - billing
Unmarshal YAML into map[string]any
package main import ( "fmt" "log" "os" oneyaml "oneyaml.dev/go" ) func main() { data, err := os.ReadFile("config.yaml") if err != nil { log.Fatal(err) } var cfg map[string]any if err := oneyaml.Unmarshal(data, &cfg); err != nil { log.Fatal(err) } server := cfg["server"].(map[string]any) port := server["port"].(int) fmt.Printf("port %d (%T), tls %v (%T)\n", port, port, server["tls"], server["tls"]) for _, f := range cfg["features"].([]any) { fmt.Println("feature:", f) } }
port 8080 (int), tls true (bool) feature: search feature: billing
What each YAML node becomes inside an any, per the Unmarshal docs:
| YAML | Go type |
|---|---|
| mapping | map[string]any, or map[any]any when a key isn’t a string |
| sequence | []any |
| integer | int, or uint64 or *big.Int when it doesn’t fit |
| float | float64 |
true, false | bool |
| null or empty | nil |
| anything else, timestamps included | string |
Plain scalars follow the YAML 1.2 Core schema, so yes stays the string "yes" and 0755 is the integer 755 (why that matters). The single-value assertions above panic if the file has a different shape. In real code use the two-value form, server, ok := cfg["server"].(map[string]any), at every level. That gets long quickly, which is the reason for the next section.
Read one value by path
Parse the file into a Document and read a value by its JSON Pointer into a variable of the type you want. Only the value at the end of the path needs a type, and a missing path or a wrong type is an error, not a panic:
package main import ( "fmt" "log" "os" oneyaml "oneyaml.dev/go" ) func main() { data, err := os.ReadFile("config.yaml") if err != nil { log.Fatal(err) } doc, err := oneyaml.Parse(data) if err != nil { log.Fatal(err) } var port int if err := doc.Get("/server/port", &port); err != nil { log.Fatal(err) } var first string if err := doc.Get("/features/0", &first); err != nil { log.Fatal(err) } fmt.Println(port, first) var tls int err = doc.Get("/server/tls", &tls) fmt.Println(err) var missing int err = doc.Get("/server/timeout", &missing) fmt.Println(err) }
8080 search oneyaml: line 5, column 8: /server/tls: cannot unmarshal !!bool `true` into int oneyaml: get "/server/timeout": path not found
The type error names the line and column in the file, which is what you want to show a person who wrote the config. Sequence items are addressed by index (/features/0). In a pointer, / inside a key is written ~1, so build pointers from data with oneyaml.Pointer("metadata", "labels", "app.kubernetes.io/name"), which escapes each part (editing).
Walk keys you don’t know in advance
When the keys themselves are data, as in a linter, a diff tool or a generic config viewer, walk the nodes. Entries and Items are range-over-func iterators, and every node knows its pointer and position:
package main import ( "fmt" "log" "os" oneyaml "oneyaml.dev/go" ) func walk(n oneyaml.Node) { switch n.Kind() { case oneyaml.MappingNode: for _, v := range n.Entries() { walk(v) } case oneyaml.SequenceNode: for item := range n.Items() { walk(item) } default: ptr, _ := n.Pointer() line, _ := n.Position() fmt.Printf("line %2d %-18s %s\n", line, ptr, n.Value()) } } func main() { data, err := os.ReadFile("config.yaml") if err != nil { log.Fatal(err) } doc, err := oneyaml.Parse(data) if err != nil { log.Fatal(err) } walk(doc.Root()) }
line 3 /server/host 0.0.0.0 line 4 /server/port 8080 line 5 /server/tls true line 7 /database/url postgres://db.internal:5432/app line 8 /database/pool 10 line 10 /features/0 search line 11 /features/1 billing
Value returns a scalar’s text as written. To get a typed value from a node, call node.Decode(&v). Nodes also carry their comments (HeadComment, LineComment), tag and anchor. The cookbook recipe read YAML without a struct shows those.
Change a value and write the file back
With a map, writing back means Marshal: the comment goes, and the keys come out sorted. A Document changes only the values you set:
package main import ( "log" "os" oneyaml "oneyaml.dev/go" ) func main() { data, err := os.ReadFile("config.yaml") if err != nil { log.Fatal(err) } doc, err := oneyaml.Parse(data) if err != nil { log.Fatal(err) } if err := doc.Set("/server/port", 9090); err != nil { log.Fatal(err) } if err := doc.Set("/features/-", "audit"); err != nil { log.Fatal(err) } if err := os.WriteFile("config.yaml", doc.Bytes(), 0o644); err != nil { log.Fatal(err) } }
# Service settings server: host: 0.0.0.0 port: 9090 tls: true database: url: postgres://db.internal:5432/app pool: 10 features: - search - billing - audit
The port changed and audit was appended (/features/- means the end of the sequence). The comment, the key order and the indentation are as they were. More in edit YAML without losing comments.
yaml.v2, yaml.v3 and map[interface{}]interface{}
Older answers to this question use yaml.v2, which returns nested mappings as map[interface{}]interface{}. That breaks the assertion to map[string]any, and encoding/json can’t marshal it. This program reads the same file untyped with three libraries:
package main import ( "encoding/json" "fmt" "os" yamlv2 "go.yaml.in/yaml/v2" yamlv3 "go.yaml.in/yaml/v3" oneyaml "oneyaml.dev/go" ) func main() { data, _ := os.ReadFile("config.yaml") decoders := []struct { name string unmarshal func([]byte, any) error }{ {"go.yaml.in/yaml/v2", yamlv2.Unmarshal}, {"go.yaml.in/yaml/v3", yamlv3.Unmarshal}, {"oneyaml.dev/go", func(b []byte, v any) error { return oneyaml.Unmarshal(b, v) }}, } for _, d := range decoders { var v any if err := d.unmarshal(data, &v); err != nil { fmt.Println(d.name, "error:", err) continue } _, jsonErr := json.Marshal(v) fmt.Printf("%-19s %T\n%-19s json.Marshal error: %v\n", d.name, v, "", jsonErr) } }
go.yaml.in/yaml/v2 map[interface {}]interface {} json.Marshal error: json: unsupported type: map[interface {}]interface {} go.yaml.in/yaml/v3 map[string]interface {} json.Marshal error: <nil> oneyaml.dev/go map[string]interface {} json.Marshal error: <nil>
yaml.v3 (here go.yaml.in/yaml/v3 v3.0.5) returns map[string]interface{} like oneyaml, and its yaml.Node type also gives you a tree with line and column numbers (pkg.go.dev). The original gopkg.in/yaml.v3 is archived, and its successor is frozen apart from security fixes; see yaml.v3 is archived: what to use instead.
Where to go next
For a typed read when you do have a struct, see the cookbook recipe parse YAML into structs. To compare Go YAML libraries, see choosing a Go YAML library. Install and first steps: getting started. All three oneyaml libraries are on the oneyaml home page. Versions on this page are the latest published on 26 October 2026.