How to Add a Configuration File to a Go CLI App

Cli
Define a struct, create a JSON file, and use encoding/json to load settings into your Go CLI app.

The missing piece in every CLI tool

You built a command-line tool. It compiles. It runs. It does exactly what you told it to do. Then you realize the port number is hardcoded to 8080 and the database URL is baked into the source. Changing either value means editing the code, running go build, and replacing the binary. That workflow breaks the moment you hand the tool to someone else or deploy it to a server.

A configuration file solves this by separating static code from dynamic settings. You define the shape of your settings once, write the values to a plain text file, and let Go read them at startup. The program stays the same. The behavior changes.

What a config file actually does

A configuration file is a structured document that acts as a contract between your program and the environment. Think of it like a blueprint for a house. The blueprint specifies where the doors, windows, and walls go. The actual materials change depending on what you buy at the hardware store. Your Go code is the blueprint. The config file supplies the materials.

In Go, you translate that contract into a struct. The struct defines the expected fields and their types. The file supplies the actual values. Go's standard library handles the translation through the encoding/json package. You do not need third-party libraries for basic configuration loading. The standard library is designed to be explicit, predictable, and fast.

Config files are not magic. They are just byte sequences that your program parses into memory. The compiler does not validate the file structure. Validation happens at runtime. If the file is missing, malformed, or contains the wrong types, your program must decide how to respond. That decision is where most beginners stumble.

The minimal working version

Start with a struct that matches the JSON structure you expect. Add struct tags to tell the JSON decoder how to map keys to fields. Open the file, decode it, and handle errors immediately.

package main

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

// Config holds the application settings loaded from disk.
type Config struct {
	// Port maps to the "port" key in the JSON file.
	Port int `json:"port"`
	// Database maps to the "database" key in the JSON file.
	Database string `json:"database"`
}

func main() {
	// Open the file and capture any OS-level error.
	file, err := os.Open("config.json")
	if err != nil {
		fmt.Fprintf(os.Stderr, "failed to open config: %v\n", err)
		os.Exit(1)
	}
	// Ensure the file descriptor closes when main returns.
	defer file.Close()

	var cfg Config
	// Decode the JSON payload directly into the struct pointer.
	if err := json.NewDecoder(file).Decode(&cfg); err != nil {
		fmt.Fprintf(os.Stderr, "failed to parse config: %v\n", err)
		os.Exit(1)
	}

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

The struct tags are the bridge between JSON keys and Go field names. Without them, the decoder looks for exact case matches. JSON uses lowercase keys by convention. Go uses PascalCase for exported fields. The json:"port" tag tells the decoder to ignore the Go field name and use port instead.

How Go reads and decodes it

When os.Open runs, the operating system returns a file descriptor. Go wraps that descriptor in an *os.File value. json.NewDecoder takes that file and creates a streaming parser. It does not load the entire file into memory at once. It reads chunks, parses JSON tokens, and feeds them into your struct field by field.

The decoder matches JSON keys to struct fields using the tags. If a key exists in the file but not in the struct, the decoder ignores it. If a field exists in the struct but not in the file, the field keeps its zero value. Integers become 0. Strings become "". Booleans become false. This behavior is intentional. Go prefers explicit defaults over silent failures.

If the JSON contains a string where an integer is expected, Decode returns an error. The compiler does not catch this. Type checking happens at runtime. If you forget to pass a pointer to Decode, the compiler rejects the program with cannot use cfg (variable of type Config) as *Config value in argument. The decoder needs a pointer because it modifies the struct in place.

The if err != nil pattern is verbose by design. The Go community accepts the boilerplate because it makes the unhappy path visible. You do not wrap errors in custom types unless you need to add context. You return them immediately. This keeps control flow linear and predictable.

Config files are just data. Treat them like any other external input: validate, handle errors, and never assume they exist.

A realistic CLI setup

Real applications rarely hardcode "config.json". Users expect the tool to look in the current directory, then fall back to a home directory location, or accept a flag to override the path. You also want to set sensible defaults so the program runs even if the file is empty.

package main

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

// Config holds the application settings loaded from disk.
type Config struct {
	Port     int    `json:"port"`
	Database string `json:"database"`
	LogLevel string `json:"log_level"`
}

// LoadConfig finds and parses the configuration file from standard locations.
func LoadConfig() (Config, error) {
	// Start with a struct that already contains safe defaults.
	cfg := Config{
		Port:     8080,
		Database: "localhost:5432",
		LogLevel: "info",
	}

	// Check the current directory first, then the user home directory.
	candidates := []string{"config.json", filepath.Join(os.Getenv("HOME"), ".myapp", "config.json")}

	for _, path := range candidates {
		// Try opening each candidate until one succeeds.
		file, err := os.Open(path)
		if err != nil {
			continue
		}
		defer file.Close()

		// Decode into the struct, overwriting defaults where the file provides values.
		if err := json.NewDecoder(file).Decode(&cfg); err != nil {
			return Config{}, fmt.Errorf("parsing %s: %w", path, err)
		}
		// Stop searching once a valid file is found.
		return cfg, nil
	}

	// Return the defaults if no config file exists anywhere.
	return cfg, nil
}

func main() {
	cfg, err := LoadConfig()
	if err != nil {
		fmt.Fprintf(os.Stderr, "config error: %v\n", err)
		os.Exit(1)
	}

	fmt.Printf("Port: %d | DB: %s | Log: %s\n", cfg.Port, cfg.Database, cfg.LogLevel)
}

The LoadConfig function starts with a fully populated struct. This guarantees that every field has a valid value even if the file is missing or empty. The decoder overwrites defaults with file values. Missing keys leave defaults untouched. This pattern eliminates nil panics and empty string bugs.

The receiver name convention applies to methods, but the same principle guides function design: keep names short and predictable. LoadConfig does one thing. It returns a struct and an error. Functions that take a context should respect cancellation, but configuration loading is usually synchronous and short-lived, so context.Context is rarely needed here.

Always return structs, not pointers to structs, unless you are dealing with large payloads or need to share state. Configuration structs are small. Passing them by value is cheap and avoids accidental aliasing.

Where things go wrong

Configuration loading fails in predictable ways. The compiler catches syntax and type mismatches. The runtime catches file system and parsing errors.

If you forget to import encoding/json, the compiler rejects the program with undefined: json. If you pass a non-pointer to Decode, you get cannot use cfg (variable of type Config) as *Config value in argument. Both are immediate feedback. Fix them before moving on.

Runtime errors happen when the file exists but contains bad data. A trailing comma in JSON triggers invalid character ',' looking for beginning of object key string. A missing quote around a string value triggers invalid character 'x' looking for beginning of string value. The decoder stops at the first error. It does not guess.

Ignoring the error from Decode is the most common mistake. If you write json.NewDecoder(file).Decode(&cfg) without checking err, the program continues with zero values or partial data. The worst configuration bug is the one that silently falls back to defaults while the operator thinks the file was read. Always check the error. Always log the path that failed.

Struct tags are case-sensitive. json:"Port" and json:"port" are different keys. If your file uses lowercase and your tag uses uppercase, the field stays zero. The decoder does not warn you about ignored keys. It assumes you know what you are doing.

Configuration files are external input. Validate them. Log failures. Never trust the disk.

When to use a config file

Use a JSON config file when you need human-readable settings that developers can edit in any text editor and version control alongside the code. Use environment variables when you are deploying to containers or cloud platforms that inject secrets and runtime overrides at startup. Use command-line flags when the user needs to override a single value for a one-off run without touching a file. Use compiled-in defaults when the value rarely changes and you want to avoid external dependencies entirely. Use a combination of all four when you need a layered configuration system that respects operator intent at every level.

Configuration is plumbing. Run it through every long-lived call site.

Where to go next