How to Watch for File Changes in Go

Use the fsnotify package to create a watcher and listen for file system events on a specific path.

The friction of restarting

You are building a CLI tool that reads a configuration file. Every time you tweak a setting, you have to kill the process and restart it. That friction kills momentum. Or maybe you are writing a backup script that needs to react the moment a file appears in a specific folder. Polling the filesystem every second works, but it wastes CPU cycles and introduces latency. The operating system already knows when files change. Go gives you a direct line to that knowledge through the fsnotify package.

How file watching works

File watching is like hiring a security guard instead of walking around checking every door yourself. Polling is you walking the perimeter every minute. Watching is the guard calling you only when something happens. Under the hood, fsnotify talks to the OS kernel. On Linux, it uses inotify. On macOS, it uses FSEvents. On Windows, it uses ReadDirectoryChangesW. The package abstracts these differences so you write one Go program that works everywhere.

The watcher runs in the background, listening for events, and pushes them into a channel when they occur. It consumes almost zero CPU while idle because it blocks on a system call. The kernel wakes the watcher only when activity happens. This makes watching efficient and responsive compared to polling.

Minimal watcher

Here is the simplest watcher: create the watcher, add a path, and loop over events.

package main

import (
	"fmt"
	"log"

	"github.com/fsnotify/fsnotify"
)

func main() {
	// Create a watcher to listen for filesystem events.
	watcher, err := fsnotify.NewWatcher()
	if err != nil {
		log.Fatal(err)
	}
	// Ensure resources are cleaned up when main exits.
	defer watcher.Close()

	// Add the directory to watch. Watching a directory captures events for files inside it.
	err = watcher.Add("/tmp/watched-dir")
	if err != nil {
		log.Fatal(err)
	}

	// Block and process events as they arrive.
	for event := range watcher.Events {
		fmt.Println("Change detected:", event.Name, event.Op)
	}
}

The range loop over watcher.Events blocks until an event arrives. When watcher.Close() is called, the channel closes and the loop exits. This pattern keeps the main goroutine alive and responsive. The defer statement guarantees cleanup even if the program panics.

What happens under the hood

When you call NewWatcher, the package sets up the OS-specific file descriptor and starts a background goroutine. That goroutine blocks on a system call, waiting for the kernel to signal activity. It consumes almost zero CPU while idle. When a file changes, the kernel wakes the goroutine, which packages the details into an fsnotify.Event and sends it down the Events channel. Your main goroutine receives the event and handles it.

The Errors channel works the same way but carries failures like permission denials or disk removals. If you ignore the Errors channel, the watcher goroutine might block forever because unbuffered channels require a receiver. Always read from both channels, or risk a deadlock. The compiler won't catch this mistake. The program simply hangs when an error occurs and nobody is listening.

Realistic config reload

Here is a watcher that reloads a config file when it changes, using a goroutine to handle events asynchronously.

// Setup watcher and start background handler.
watcher, err := fsnotify.NewWatcher()
if err != nil {
	log.Fatal(err)
}
defer watcher.Close()

go handleEvents(watcher)

// Watch the config file directly.
err = watcher.Add("config.yaml")
if err != nil {
	log.Fatal(err)
}

The goroutine runs independently, allowing the main function to do other work or wait for shutdown signals.

// handleEvents processes changes and errors from the watcher.
func handleEvents(watcher *fsnotify.Watcher) {
	for {
		select {
		case event, ok := <-watcher.Events:
			if !ok {
				return
			}
			// Filter for write operations to ignore creates/deletes if needed.
			if event.Op&fsnotify.Write == fsnotify.Write {
				fmt.Println("File modified:", event.Name)
			}
		case err, ok := <-watcher.Errors:
			if !ok {
				return
			}
			log.Printf("Error: %v", err)
		}
	}
}

The select statement allows the handler to react to both events and errors. The ok check handles channel closure gracefully. If the watcher closes, the goroutine returns and exits cleanly. This prevents goroutine leaks when the watcher is shut down.

Directory vs file watching

Watch directories, not files. Many editors save by creating a temp file and renaming it over the original. If you watch the file path, you might miss the rename event or lose the watch entirely. Watching the parent directory captures the new file appearing. The event name tells you which file changed. This approach is more robust across different editors and operating systems.

If you watch a directory, you get events for all files inside it. Filter by filename if you only care about specific files. The event.Name field contains the full path of the changed file. Check it against your target before processing.

Event operations and bitwise checks

Event operations use bitwise flags. A single event can carry multiple operations. Check for specific ops using bitwise AND. This prevents false positives when a file is renamed and written simultaneously.

// Check if the event includes a write operation.
if event.Op&fsnotify.Write == fsnotify.Write {
	// Handle write.
}

The Op field is a bitmask. fsnotify.Write, fsnotify.Create, fsnotify.Remove, fsnotify.Rename, and fsnotify.Chmod are all flags. An event might have Write | Chmod. Using equality checks like event.Op == fsnotify.Write fails when multiple flags are set. Always use bitwise AND to test for specific operations.

Pitfalls and errors

If you add a path that does not exist, the runtime returns an error from Add. If you ignore that error, the watcher silently fails to monitor the path. The compiler rejects the program with undefined: fsnotify if you forget the import. If you try to use a watcher after calling Close, operations return an error.

Event storms are common. Saving a file in an editor often triggers multiple events: Write, Chmod, sometimes Rename followed by Write. Your handler might run three times for one save. Debounce or coalesce events if you need a single reaction. A simple timer reset on each event can delay processing until the storm settles.

Symlinks behave differently across platforms. fsnotify follows symlinks by default. If you watch a symlink to a directory, you get events for the target directory. If the symlink itself changes, you might not get an event depending on the OS. Test symlink behavior on your target platforms if your application relies on them.

Always drain the error channel. A silent watcher is a dead program. If an error occurs and nobody reads it, the internal goroutine blocks. The program hangs with no indication of failure. Log errors even if you cannot fix them. At least you know something went wrong.

Convention aside: defer watcher.Close() is standard. Resources must be released. The community expects watchers to be closed when done. If your watcher runs inside a service, pass context.Context as the first argument to your handler function so you can cancel the watch when the service shuts down. Context is plumbing. Run it through every long-lived call site.

Decision matrix

Use fsnotify when you need to react to file changes with low latency and minimal CPU usage. Use a periodic poll with time.Ticker when you are watching a network filesystem where kernel notifications are unreliable or unsupported. Use os.Stat in a loop when you only need to check a file's existence or size at specific intervals and don't need event details. Use a database trigger or message queue when the data source is not a local file system.

Polling is predictable. Watching is efficient. Pick based on your filesystem, not your preference.

Where to go next