How to Implement Configuration Hot Reload in Go

Go requires a custom file watcher to implement configuration hot reloading since it lacks native support for this feature.

The midnight config change

A production server is running. You edit a YAML file to adjust a rate limit or flip a feature flag. Restarting the process drops active connections and takes thirty seconds to warm up. You want the change to take effect the moment you save the file. That is configuration hot reloading.

Go does not ship with a built-in hot reload mechanism. The language treats configuration as just another piece of data. You are responsible for watching the file, parsing the new bytes, and swapping the old values out while the rest of your program keeps running. The challenge is not reading the file. The challenge is updating shared state without stopping the goroutines that depend on it.

How hot reloading actually works

Hot reloading is just a background goroutine that waits for a file change, reads the new content, and replaces a pointer to your configuration struct. Think of it like swapping a battery in a device that stays powered. The old battery drains out, the new one clicks in, and the device never blinks. In Go, you achieve this by storing your config behind a thread-safe swap mechanism. The most idiomatic choice is sync/atomic.Value. It lets you store and load pointers without locks, and it guarantees that readers never see a partially written value.

You also need a file watcher. Polling a directory every two seconds works in a pinch, but it wastes CPU cycles and introduces delay. The standard approach is fsnotify, which hooks into the operating system's native file event system. When the file changes, the watcher fires a callback. Your callback reads the file, parses it, validates it, and atomically stores the new config.

The minimal safe swap

Here is the simplest way to wire a watcher to an atomic config store. The code spawns a watcher, waits for changes, and swaps the config pointer.

package main

import (
	"fmt"
	"sync/atomic"
	"time"

	"github.com/fsnotify/fsnotify"
)

// Config holds the application settings.
type Config struct {
	RateLimit int
	Timeout   time.Duration
}

// configStore holds the current configuration safely.
var configStore atomic.Value

func main() {
	// Load initial config before starting the watcher.
	loadAndStore("config.yaml")

	// Watch the file for modifications.
	watcher, _ := fsnotify.NewWatcher()
	defer watcher.Close()
	watcher.Add("config.yaml")

	// Block and react to file events.
	for {
		select {
		case event := <-watcher.Events:
			if event.Op&fsnotify.Write == fsnotify.Write {
				loadAndStore("config.yaml")
				fmt.Println("Config reloaded")
			}
		case err := <-watcher.Errors:
			fmt.Println("Watcher error:", err)
		}
	}
}

// loadAndStore reads the file and atomically replaces the config.
func loadAndStore(path string) {
	// Parse YAML/JSON into a new struct instance.
	newCfg := &Config{RateLimit: 100, Timeout: 5 * time.Second}
	configStore.Store(newCfg)
}

The watcher runs in the main goroutine here for simplicity, but in a real service you would run it in a background goroutine. The atomic.Value handles the swap. When Store is called, the old pointer is replaced. Any concurrent call to Load will see either the old pointer or the new pointer, never a corrupted half-written struct. This is why you store pointers in atomic.Value. The atomic operation only moves the pointer itself, not the entire struct.

Go convention dictates that public names start with a capital letter and private names start lowercase. Config is public so other packages can read it. configStore is private because only this package should mutate it. The community also expects if err != nil { return err } boilerplate. It looks verbose, but it makes the failure path impossible to ignore. You would normally wrap the parsing logic in a function that returns an error, check it, and only store the config if parsing succeeds.

Wiring it into a real service

A background watcher is useless if your HTTP handlers or background workers keep reading a stale global variable. You need a way for other parts of your code to fetch the current config without introducing race conditions. The cleanest pattern is a small accessor function that reads from the atomic store and type-asserts the result.

Here is how you expose the config to an HTTP handler while respecting Go's concurrency model.

package main

import (
	"encoding/json"
	"net/http"
	"sync/atomic"
)

// currentConfig returns the latest configuration snapshot.
func currentConfig() *Config {
	// Load returns an interface{}, so we assert it back to *Config.
	val := configStore.Load()
	if val == nil {
		return &Config{} // fallback to defaults
	}
	return val.(*Config)
}

// handleStatus writes the active config as JSON.
func handleStatus(w http.ResponseWriter, r *http.Request) {
	// Fetch a fresh pointer on every request.
	cfg := currentConfig()
	w.Header().Set("Content-Type", "application/json")
	json.NewEncoder(w).Encode(cfg)
}

The handler calls currentConfig() on every request. This guarantees it always sees the latest values. You do not cache the pointer in the handler. You do not pass the config through a closure that captures a stale reference. You read it fresh. The atomic load is extremely fast, usually a single CPU instruction on modern architectures.

When you design functions that depend on external state, Go convention favors passing context.Context as the first parameter, conventionally named ctx. Functions that accept a context should respect cancellation and deadlines. If your config reload triggers a long-running migration or database sync, you would pass ctx down so the operation can abort if the server is shutting down. The receiver name for methods is usually one or two letters matching the type, like (c *Config) Validate(), not (this *Config) or (self *Config). Keep it consistent with the standard library.

Where things break

Hot reloading introduces concurrency hazards that do not exist in static config setups. The most common failure is a goroutine leak. If your watcher goroutine blocks on a channel that never receives, or if you forget to call watcher.Close(), the goroutine stays alive after your main function exits. The worst goroutine bug is the one that never logs. Always pair a background goroutine with a context.Context or a done channel, and cancel it during shutdown.

Race conditions happen when you bypass the atomic store. If you write config = newConfig on a plain global variable while another goroutine reads it, the Go race detector will catch it during testing. Run go run -race main.go and you will see output like WARNING: DATA RACE pointing to the exact line. The compiler itself will not stop you, but the race detector will fail your build if you treat it as an error.

File parsing errors are another trap. If your YAML has a typo, a naive reload function might overwrite the working config with a zero-value struct. You must validate the new config before storing it. If validation fails, log the error and keep the old config. The compiler will complain with cannot use x (type string) as int value in assignment if you mix up types during parsing, but runtime validation catches structural mistakes.

You also need to handle missing imports correctly. Forget to import a package and you get undefined: fsnotify from the compiler. Forget to use one and you get imported and not used. Go is strict about unused imports because they imply dead code. Run gofmt on save. It is mandatory. Do not argue about indentation or brace placement. Let the tool decide. Most editors run it automatically, and it keeps the codebase uniform without style debates.

Picking the right reload strategy

Configuration reloading is not one-size-fits-all. The right tool depends on your deployment environment, team size, and latency requirements.

Use a polling loop when you are on a constrained system without inotify, kqueue, or ReadDirectoryChangesW support. Polling is predictable and requires no external dependencies, but it adds latency and CPU overhead.

Use fsnotify when you need immediate reaction to file changes on Linux, macOS, or Windows. It hooks into the OS event system, consumes almost no CPU while idle, and is the standard choice for local development and containerized deployments.

Use a config management service when your team needs versioned, audited, and distributed configuration across dozens of instances. Services like Consul, etcd, or AWS Parameter Store handle replication, encryption, and access control. You trade file simplicity for operational robustness.

Use a reload endpoint when you prefer explicit control over automatic triggers. A POST /reload route lets you validate changes in CI/CD pipelines, roll back instantly, and avoid accidental production edits. It shifts the responsibility from the filesystem to your deployment workflow.

Where to go next