---
title: "Read YAML in Go without a struct"
url: https://recursiveai.org/oneyaml/articles/golang-read-yaml-without-struct/
description: "Read a YAML file in Go without defining a struct: unmarshal into map[string]any, read one value by path, walk unknown keys with line numbers, and write it back."
date: 2026-10-26
author: oneyaml team
---

# Read YAML in Go without a struct

Read a YAML file in Go without defining a struct: unmarshal into map[string]any, read one value by path, walk unknown keys with line numbers, and write it back.

October 26, 2026 · oneyaml team

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:

*config.yaml*

```
# 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

*main.go · oneyaml.dev/go v1.0.0*

```
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)
    }
}
```

*Output*

```
port 8080 (int), tls true (bool)
feature: search
feature: billing
```

What each YAML node becomes inside an `any`, per the [`Unmarshal` docs](https://pkg.go.dev/oneyaml.dev/go#Unmarshal):

| 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](https://recursiveai.org/oneyaml/articles/yaml-norway-problem/)). 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](https://www.rfc-editor.org/rfc/rfc6901) 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:

*main.go · oneyaml.dev/go v1.0.0*

```
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)
}
```

*Output*

```
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](https://recursiveai.org/oneyaml/go/docs/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:

*main.go · oneyaml.dev/go v1.0.0*

```
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())
}
```

*Output*

```
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](https://recursiveai.org/oneyaml/go/cookbook/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:

*main.go · oneyaml.dev/go v1.0.0*

```
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)
    }
}
```

*config.yaml after the run*

```
# 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](https://recursiveai.org/oneyaml/articles/edit-yaml-keep-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:

*main.go · Go 1.26*

```
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)
    }
}
```

*Output*

```
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](https://pkg.go.dev/go.yaml.in/yaml/v3#Node)). 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](https://recursiveai.org/oneyaml/articles/yaml-v3-alternative/).

## Where to go next

For a typed read when you do have a struct, see the cookbook recipe [parse YAML into structs](https://recursiveai.org/oneyaml/go/cookbook/parse-yaml-into-structs/). To compare Go YAML libraries, see [choosing a Go YAML library](https://recursiveai.org/oneyaml/articles/golang-yaml/). Install and first steps: [getting started](https://recursiveai.org/oneyaml/go/docs/getting-started/). All three oneyaml libraries are on the [oneyaml home page](https://recursiveai.org/oneyaml/). Versions on this page are the latest published on 26 October 2026.
