How to Read and Write TOML in Go

Use the go-toml library to unmarshal TOML files into Go structs and marshal structs back to TOML files.

The config file that doesn't fight back

You are building a command-line tool that needs to know where the database lives and how verbose the logs should be. You could use JSON, but editing nested braces and quotes by hand feels like fighting the editor. You could try YAML, but indentation rules and parser disagreements make simple edits risky. TOML sits in the middle. It looks like the INI files from the early web, but it supports typed values, arrays, and nested tables. It is strict by design. If the file is malformed, the parser fails immediately instead of loading half the config and crashing later.

Struct tags bridge the gap

Go structs are rigid containers. The field names in your code rarely match the keys in a configuration file. Struct tags bridge that gap. A tag is a short string attached to a struct field that tells the parsing library exactly where to put the data. Think of it like a shipping label on a box. The box is your struct field. The label says toml:"database_host". When the library reads the file, it scans for that exact key and drops the value into the matching field. If a field has no tag, the library falls back to the field name, usually lowercased. This lets you keep idiomatic CamelCase names in Go while your config file uses snake_case.

Tags are the contract between your code and your config file. If the contract breaks, the data does not load.

Minimal example

Install the standard library first. The v2 release is the current community default. It implements the full TOML specification and returns precise error messages.

go get github.com/pelletier/go-toml/v2

Here is the simplest round-trip. Define a struct with tags, read a file, change a value, and write it back.

package main

import (
	"log"

	"github.com/pelletier/go-toml/v2"
)

// Config holds the application settings.
type Config struct {
	// tag maps the 'name' key to this field
	Name    string `toml:"name"`
	Version int    `toml:"version"`
}

func main() {
	var cfg Config

	// UnmarshalFile reads the disk file and populates the struct
	if err := toml.UnmarshalFile("config.toml", &cfg); err != nil {
		log.Fatalf("failed to read config: %v", err)
	}

	// modify a value in memory
	cfg.Name = "MyApp"
	cfg.Version = 2

	// MarshalFile serializes the struct back to disk
	if err := toml.MarshalFile(cfg, "output.toml"); err != nil {
		log.Fatalf("failed to write config: %v", err)
	}
}

The if err != nil pattern is verbose by design. The Go community accepts the boilerplate because it forces you to handle the unhappy path immediately. You never ignore a parsing failure. If the file is missing or malformed, the program stops. That is safer than continuing with empty defaults.

TOML is strict. If the file is wrong, the program fails. That is a feature, not a bug.

What happens under the hood

When you call UnmarshalFile, the library opens the file and reads the raw bytes. It parses the TOML syntax into an internal tree structure. It validates types as it walks the tree. A string in TOML must map to a string in Go. An integer must map to an integer. If the file contains a boolean but your struct expects a string, the parser rejects the input.

Next, the library inspects your struct using reflection. Reflection lets Go programs examine their own type information at runtime. The library iterates over every field in your struct. It checks for the toml tag. It finds the matching key in the parsed tree. It converts the value and writes it directly into the struct memory.

If a key is missing in the TOML file, the field stays at its zero value. A string becomes an empty string. An integer becomes zero. A slice becomes nil. The parser does not panic. It does not warn. It leaves the field alone. Missing keys silently produce zero values. You must validate the struct after parsing if you have required fields.

Reflection has a performance cost. Parsing TOML is slower than reading a flat key-value file. The library pays the price of inspecting types and matching tags at runtime. Parse the configuration once at startup and reuse the struct. Do not read the file on every request.

Convention aside: gofmt does not care about struct field order, but the community usually groups exported fields first, then unexported fields, then methods. Let the formatter handle alignment. Argue logic, not spacing.

Reflection is a runtime tax. Pay it once at startup.

Realistic example with nested data

Real configurations have sections and lists. TOML uses [[array]] syntax for lists of tables. Go maps this to slices of structs. You also need to handle optional fields. The omitempty tag tells the marshaler to skip a field if it holds a zero value. This keeps the output file clean.

Here is a configuration with a nested server section and a list of upstream hosts.

package main

import (
	"log"

	"github.com/pelletier/go-toml/v2"
)

// Server defines a single endpoint configuration.
type Server struct {
	Host string `toml:"host"`
	Port int    `toml:"port"`
}

// Config holds the full application configuration.
type Config struct {
	// omitempty skips the field if it is false
	Debug   bool     `toml:"debug,omitempty"`
	Servers []Server `toml:"servers"`
}

func main() {
	var cfg Config

	// UnmarshalFile handles nested tables and arrays automatically
	if err := toml.UnmarshalFile("config.toml", &cfg); err != nil {
		log.Fatalf("config error: %v", err)
	}

	// validate required fields manually
	if len(cfg.Servers) == 0 {
		log.Fatal("at least one server is required")
	}

	// MarshalFile writes back with consistent formatting
	if err := toml.MarshalFile(cfg, "config.toml"); err != nil {
		log.Fatalf("write error: %v", err)
	}
}

The Servers field is a slice. The library maps [[servers]] blocks in the TOML file to elements in the slice. If the file contains three blocks, the slice holds three elements. The order is preserved.

Public names start with a capital letter. Private names start lowercase. The marshaler only sees public fields. If you define a secretKey string field, the library ignores it. It cannot read or write unexported fields. This is a safety feature. You cannot accidentally leak internal state into the config file. If you want to skip a public field, use the tag toml:"-".

Tags are the contract. If the tag is wrong, the data vanishes.

Pitfalls and error handling

The most common mistake is a tag mismatch. TOML keys are case-sensitive. If your file contains Name but your tag says toml:"name", the field stays empty. The parser does not warn you. You get a silent bug where your config looks correct but values are missing. Always check the struct after parsing. Print the values or run a validation function.

Another trap is the zero value. If you have a Timeout int field and the key is missing, Timeout is 0. Zero might be a valid timeout. If it is not, you need a way to distinguish between missing and zero. Use a pointer type like *int. If the key is missing, the pointer is nil. If the key is present and zero, the pointer points to 0. This adds a layer of indirection but gives you precision. The convention in Go is to avoid *string for simple values because strings are cheap to pass by value, but pointers are the standard way to track optional configuration fields.

The compiler catches structural mistakes early. If you attach a toml tag to an unexported field, the compiler rejects the program with cannot unmarshal TOML into struct: field X is unexported. The compiler also validates tag syntax. If you use a malformed tag, you get a compile-time error before the program runs.

Runtime errors behave differently. If the file has a type mismatch, Unmarshal returns an error instead of panicking. The error message is descriptive. It tells you the key, the expected type, and the actual type. For example, type mismatch for key "port": expected integer, got string. You can parse this error and show a helpful message to the user.

Do not hide configuration errors. Print them and exit. A config error at startup is better than a crash in production.

Validate early. Fail loud. Keep defaults explicit.

Custom unmarshaling

Sometimes the built-in types are not enough. You might want a duration field that accepts 10s, 5m, or 1h. Or a list of strings that can also be a single string. You can implement the toml.Unmarshaler interface. The library calls your method when it encounters the field.

Here is a custom duration type that parses TOML strings into Go durations.

package main

import (
	"fmt"
	"time"

	"github.com/pelletier/go-toml/v2"
)

// Duration wraps time.Duration and adds TOML support.
type Duration struct {
	time.Duration
}

// UnmarshalTOML parses the raw TOML bytes into a duration.
func (d *Duration) UnmarshalTOML(b []byte) error {
	// convert bytes to string for easier manipulation
	s := string(b)
	// strip surrounding quotes if the parser left them
	if len(s) >= 2 && s[0] == '"' && s[len(s)-1] == '"' {
		s = s[1 : len(s)-1]
	}

	// parse the cleaned string into a standard duration
	parsed, err := time.ParseDuration(s)
	if err != nil {
		return fmt.Errorf("invalid duration %q: %w", s, err)
	}

	d.Duration = parsed
	return nil
}

The method receives the raw bytes from the TOML file. You parse them and set the struct field. If parsing fails, return an error. The library stops and propagates the error. This lets you support custom formats without breaking the rest of the config.

Custom types give you full control. Use them when the standard types are too rigid.

Decision matrix

Use go-toml/v2 when you need human-readable configuration files with strict typing and nested structures. Use JSON when you are building an API and machines communicate with machines. Use YAML when you are forced to by Kubernetes or Docker Compose, despite the parsing risks. Use a simple INI parser when you only need flat key-value pairs and want zero dependencies.

TOML is the sweet spot for application configuration. It is safe, readable, and supported by the best library in the ecosystem.

Trust the parser. Validate the result. Keep your config files simple.

Where to go next