How to Use Configuration Files (JSON, YAML, TOML) in Go

Use encoding/json for JSON files and third-party libraries like gopkg.in/yaml.v3 or github.com/BurntSushi/toml to load YAML and TOML configurations into Go structs.

Hardcoded configuration breaks in production

You write a Go server. It works perfectly on your laptop. You deploy it to a cloud instance. The server crashes immediately. The port is hardcoded to 8080, but the cloud provider assigned 9090. Or the database URL is wrong. Or the API key is missing. Hardcoding configuration is the fastest way to break your application the moment it leaves your development environment.

Configuration files solve this problem. They externalize settings so you can change behavior without recompiling code. Go does not treat configuration files as a special category. It treats them as plain text files containing bytes. Your job is to read those bytes and transform them into a Go struct. The struct defines what your application expects. The file provides the data.

Go's standard library includes encoding/json. It handles JSON files out of the box. For YAML and TOML, you use third-party libraries. The pattern is identical across all formats: read the file, unmarshal the bytes into a struct, check for errors, and use the data.

The struct is the contract

A Go struct is more than a data container. It is the contract between your code and the configuration file. The struct fields define the keys your application needs. The types define the shape of the data. If the file contains extra keys, Go ignores them. If the file is missing keys, Go fills in zero values. This behavior makes configuration resilient to changes. You can add new fields to your struct without breaking old config files, as long as you handle the zero values correctly.

Struct tags are the bridge between the file format and your code. JSON keys are strings. Go field names are identifiers. Tags tell the parser how to map one to the other. A tag looks like backticks containing a key-value pair. The format is json:"key". The parser reads the tag and knows that the JSON key key maps to the Go field.

package main

import (
	"encoding/json"
	"fmt"
	"os"
)

// Config holds the application settings.
// Tags map JSON keys to struct fields.
type Config struct {
	Port int    `json:"port"`
	Name string `json:"name"`
}

func main() {
	// Read the file into memory. Returns bytes or an error.
	data, err := os.ReadFile("config.json")
	if err != nil {
		// Exit immediately if the file cannot be read.
		fmt.Fprintf(os.Stderr, "failed to read config: %v\n", err)
		os.Exit(1)
	}

	var cfg Config
	// Parse bytes into the struct. Tags map keys to fields.
	if err := json.Unmarshal(data, &cfg); err != nil {
		fmt.Fprintf(os.Stderr, "failed to parse config: %v\n", err)
		os.Exit(1)
	}

	fmt.Printf("Running %s on port %d\n", cfg.Name, cfg.Port)
}

The code above reads config.json, unmarshals it into cfg, and prints the values. If the file is missing, os.ReadFile returns an error. If the JSON is invalid, json.Unmarshal returns an error. You check both. You do not panic in production code. You log the error and exit. This is the standard Go error handling pattern. The verbose if err != nil block makes the unhappy path visible. The community accepts the boilerplate because it prevents silent failures.

YAML and TOML follow the same pattern

JSON is built into Go. YAML and TOML are not. You must import third-party libraries. The most common choices are gopkg.in/yaml.v3 for YAML and github.com/BurntSushi/toml for TOML. These libraries follow the same interface as the standard library. You call Unmarshal with the file bytes and a pointer to your struct.

The import path matters. gopkg.in/yaml.v3 uses semantic versioning. The .v3 suffix ensures you get the stable major version. Go modules handle the dependency resolution. You run go get gopkg.in/yaml.v3 to download the library. Then you import it in your code.

package main

import (
	"fmt"
	"os"

	"gopkg.in/yaml.v3"
)

// Config holds the application settings.
// YAML tags use the same syntax as JSON tags.
type Config struct {
	Port int    `yaml:"port"`
	Name string `yaml:"name"`
}

func main() {
	// Read the YAML file. Returns bytes or an error.
	data, err := os.ReadFile("config.yaml")
	if err != nil {
		fmt.Fprintf(os.Stderr, "failed to read config: %v\n", err)
		os.Exit(1)
	}

	var cfg Config
	// Unmarshal YAML bytes into the struct.
	if err := yaml.Unmarshal(data, &cfg); err != nil {
		fmt.Fprintf(os.Stderr, "failed to parse config: %v\n", err)
		os.Exit(1)
	}

	fmt.Printf("Running %s on port %d\n", cfg.Name, cfg.Port)
}

The only difference is the import and the function name. yaml.Unmarshal replaces json.Unmarshal. The struct tags change from json to yaml. The logic remains identical. TOML works the same way with toml.Unmarshal. This consistency reduces cognitive load. You learn the pattern once and apply it to any format.

Handling missing values and validation

Zero values can be tricky. An int field defaults to 0. A string defaults to an empty string. A bool defaults to false. If your config requires a port, and the file omits the port, you get 0. You must validate this. Go does not validate configuration automatically. You write the checks.

Pointers solve the ambiguity of zero values. A *int field is nil if the key is missing. It points to a value if the key exists, even if that value is 0. This distinction matters for optional configuration. If a field is optional, use a pointer. If a field is required, use a value type and check for the zero value.

package main

import (
	"encoding/json"
	"fmt"
	"os"
)

// Config uses pointers for optional fields.
// A nil pointer means the key was missing in the file.
type Config struct {
	Port *int   `json:"port"`
	Name string `json:"name"`
}

func main() {
	data, err := os.ReadFile("config.json")
	if err != nil {
		fmt.Fprintf(os.Stderr, "failed to read config: %v\n", err)
		os.Exit(1)
	}

	var cfg Config
	if err := json.Unmarshal(data, &cfg); err != nil {
		fmt.Fprintf(os.Stderr, "failed to parse config: %v\n", err)
		os.Exit(1)
	}

	// Check if port was provided. Nil means missing.
	if cfg.Port == nil {
		fmt.Fprintln(os.Stderr, "port is required")
		os.Exit(1)
	}

	// Validate the port range.
	if *cfg.Port < 1 || *cfg.Port > 65535 {
		fmt.Fprintln(os.Stderr, "port must be between 1 and 65535")
		os.Exit(1)
	}

	fmt.Printf("Running on port %d\n", *cfg.Port)
}

The code above uses *int for the port. If the JSON file omits the port, cfg.Port is nil. You check for nil and exit with an error. If the port is present, you dereference the pointer and validate the range. This pattern gives you full control over what is required and what is optional.

Validation should happen immediately after unmarshaling. Do not defer checks until runtime. Fail fast. If the config is invalid, the application should not start. This prevents confusing errors later. You can wrap the validation in a function to keep main clean. The function returns an error if validation fails. You check the error and exit.

Pitfalls and compiler errors

Unexported fields are invisible to the parser. If your struct has a lowercase field, the parser skips it. The compiler does not warn you. The data simply does not load. Always capitalize config fields. Public names start with a capital letter. Private names start lowercase. This is a core Go convention. The parser uses reflection to access fields. Reflection cannot access unexported fields.

Case sensitivity matters in tags. The tag json:"Port" maps to the JSON key Port. The tag json:"port" maps to port. If the file uses lowercase keys and your tag uses uppercase, the field remains zero. Double-check your tags against your file format.

File paths can be relative or absolute. os.ReadFile("config.json") looks for the file in the current working directory. The working directory depends on how you run the program. If you run go run main.go, the directory is where you executed the command. If you run the binary from another directory, the file might not be found. Use os.Getwd to debug path issues. Or use absolute paths in production.

Invalid syntax causes unmarshal errors. If the JSON is malformed, json.Unmarshal returns an error like invalid character '}' looking for beginning of object key string. The error message tells you what went wrong. Log the error and exit. Do not ignore it.

If you forget to import a package, the compiler rejects the program with undefined: yaml. If you import a package but do not use it, the compiler rejects the program with imported and not used. Go enforces clean imports. Remove unused imports to satisfy the compiler.

Decision matrix

Pick the configuration format that matches your team's workflow and deployment environment.

Use JSON when you need standard library support and machine readability. JSON is ubiquitous. Every language can parse it. The standard library is fast and reliable. Use JSON for APIs and automated tooling.

Use YAML when humans edit the file frequently and you need comments. YAML supports comments, multi-line strings, and anchors. It is more readable for complex configurations. Use YAML for Kubernetes manifests and infrastructure-as-code.

Use TOML when you want a strict, simple format for configuration. TOML is designed for config files. It has fewer features than YAML, which reduces ambiguity. It is easy to parse and write. Use TOML for application settings and simple deployments.

Use environment variables when secrets or platform-specific values change per deployment. Environment variables are injected by the runtime environment. They are not stored in files. Use environment variables for database passwords and API keys.

Use command-line flags when you need quick overrides without editing files. Flags are parsed at startup. They allow users to change settings on the fly. Use flags for debugging and testing.

Combine formats for flexibility. Load defaults from a file. Override with environment variables. Override again with command-line flags. This layered approach gives you maximum control.

Config is data. Treat it like data. Validate it. Log errors. Fail fast.

Where to go next