How to Use Filesystem Notifications in Go (fsnotify)

Use fsnotify to watch directories for file changes and handle events via a channel.

Watching files without polling

You are building a CLI tool that watches a directory for changes. Maybe a hot-reload server, or a backup script that triggers when a file appears. You don't want to poll the filesystem every second. Polling burns CPU cycles, introduces latency, and can miss rapid changes if a file appears and vanishes between checks. You need the operating system to tell you when something happens.

Go's standard library doesn't include a filesystem watcher. The APIs differ too much across platforms. Linux uses inotify. macOS and BSD use kqueue. Windows uses ReadDirectoryChangesW. Each has its own quirks, flags, and limitations. Writing a cross-platform watcher from scratch means wrestling with C bindings and platform-specific edge cases.

github.com/fsnotify/fsnotify wraps these OS-specific calls behind a single Go interface. It handles the platform differences so you don't have to. You get a unified API that works everywhere Go runs. The watcher runs in the background, reads events from the OS, and pushes them into Go channels. Your code reads from those channels. This fits the Go concurrency model perfectly. You select on the event channel alongside other work.

The OS does the heavy lifting. fsnotify translates it to channels.

How fsnotify bridges the gap

Under the hood, fsnotify creates a watcher instance that talks to the kernel. On Linux, it opens an inotify instance and adds watches. On macOS, it sets up a kqueue and registers file descriptors. On Windows, it calls ReadDirectoryChangesW on a directory handle.

The library starts a background goroutine that blocks on the OS API. When the kernel reports a change, the goroutine receives the raw event, translates it into a fsnotify.Event struct, and sends it to the Events channel. If something goes wrong, like a permission error or a removed watch, the error goes to the Errors channel.

Your code interacts with the watcher through three main operations:

  • NewWatcher creates the watcher and starts the background goroutine.
  • Add registers a path for monitoring.
  • Close stops the watcher and releases OS resources.

The Events and Errors channels are unbuffered. The background goroutine blocks if your code doesn't read from them. This backpressure prevents memory leaks. If your processing is slow, the watcher waits. It doesn't queue up thousands of events until you run out of memory.

Convention aside: always call defer watcher.Close() right after creating the watcher. The watcher holds an OS file descriptor. If you skip the close, the descriptor leaks. On Linux, you can hit the inotify instance limit and break other tools. The community treats defer watcher.Close() as standard boilerplate, just like defer file.Close().

The watcher is a goroutine in disguise. Close it when you're done.

Minimal watcher

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

package main

import (
	"github.com/fsnotify/fsnotify"
)

func main() {
	// NewWatcher allocates the watcher and starts the internal goroutine
	watcher, err := fsnotify.NewWatcher()
	if err != nil {
		panic(err)
	}
	// Close releases the OS file descriptor; defer guarantees cleanup
	defer watcher.Close()

	// Add registers the path with the OS; returns error if path is missing
	err = watcher.Add("/tmp/watchdir")
	if err != nil {
		panic(err)
	}
}

The setup is straightforward. NewWatcher returns an error if the OS can't initialize the watcher. Add returns an error if the path doesn't exist or you lack permissions. The compiler won't catch missing paths; this is a runtime check.

If you forget to import the package, the compiler rejects the program with undefined: fsnotify. If you pass the wrong type to Add, you get cannot use ... as string value in argument. These are standard Go errors.

Now you need to read the events. The watcher runs in the background, so you must block and consume the channels.

// The select loop processes events and errors as they arrive
for {
	select {
	case event, ok := <-watcher.Events:
		if !ok {
			return // Channel closed indicates the watcher has stopped
		}
		fmt.Println("Event:", event)
	case err, ok := <-watcher.Errors:
		if !ok {
			return
		}
		fmt.Println("Error:", err)
	}
}

The select statement waits on both channels. When an event arrives, the first case runs. When an error occurs, the second case runs. The ok check on the channel receive is crucial. When Close is called, the channels close. Reading from a closed channel returns the zero value and false. The if !ok guard detects this and exits the loop cleanly. Without the guard, the loop would continue processing zero-value events forever.

Convention aside: the if err != nil { return err } pattern is verbose by design. The community accepts the boilerplate because it makes the unhappy path visible. In a watcher, errors can indicate serious issues like disk full or permission changes. Logging or returning the error is better than panicking.

The watcher is a goroutine in disguise. Close it when you're done.

Anatomy of the loop

The loop structure matters. You are multiplexing two channels. The select statement picks a random case if multiple are ready. This prevents starvation. If errors pile up, you still process events. If events pile up, you still process errors.

The ok idiom protects against closed channels. When the watcher closes, both channels close. The ok check returns false. You break out of the loop. This is the standard way to handle channel closure in Go.

Some codebases use range over the events channel. This works if you only care about events and ignore errors. However, range doesn't let you handle errors gracefully. If an error occurs, the range loop continues, and the error is lost. Using select with explicit ok checks gives you full control.

The Event struct contains useful fields:

  • Name is the path that changed.
  • Op is a bitmask of operations like Create, Write, Remove, Rename, Chmod.
  • Op can combine multiple operations. Use bitwise AND to check for specific ops.

For example, event.Op&fsnotify.Create == fsnotify.Create checks if the create bit is set. This is safer than checking equality, because the op might include other flags.

Convention aside: receiver names in Go are usually one or two letters matching the type. If you write a method on a custom watcher, use (w *Watcher), not (this *Watcher). This keeps the code concise and idiomatic.

The watcher is a goroutine in disguise. Close it when you're done.

Real-world: reloading config

Real tools need more than printing events. You might want to reload a config file when it changes. A common pattern is to pass a handler function. This keeps the watcher logic separate from the business logic.

You also need cancellation. Long-running watchers should respect context.Context. If the user presses Ctrl+C, the context cancels, and the watcher stops. Context is plumbing. Run it through every long-lived call site.

// WatchConfig monitors a file and calls reload when it changes
func WatchConfig(ctx context.Context, path string, reload func() error) error {
	watcher, err := fsnotify.NewWatcher()
	if err != nil {
		return err
	}
	defer watcher.Close()

	if err := watcher.Add(path); err != nil {
		return err
	}

The function takes a context, a path, and a reload callback. It creates the watcher and adds the path. Errors are returned immediately. The defer ensures cleanup.

	for {
		select {
		case <-ctx.Done():
			return ctx.Err() // Cancelled by caller
		case event, ok := <-watcher.Events:
			if !ok {
				return nil
			}
			// Only reload on Write; ignore other noise
			if event.Op&fsnotify.Write == fsnotify.Write {
				if err := reload(); err != nil {
					return fmt.Errorf("reload failed: %w", err)
				}
			}
		case err, ok := <-watcher.Errors:
			if !ok {
				return nil
			}
			return fmt.Errorf("watch error: %w", err)
		}
	}
}

The loop now includes ctx.Done(). If the context cancels, the function returns the context error. This allows graceful shutdown. The event handler checks for Write operations. This filters out Create or Remove events that might not require a reload. The reload function is called, and errors are wrapped with fmt.Errorf and %w. This preserves the error chain for callers.

Convention aside: context.Context always goes as the first parameter, conventionally named ctx. Functions that take a context should respect cancellation and deadlines. This is a universal Go convention.

The worst goroutine bug is the one that never logs.

Pitfalls and quirks

Filesystem notifications have edge cases. fsnotify handles many, but you need to know the limits.

Recursion is manual. fsnotify watches a single path. It does not watch subdirectories automatically. If you add /tmp/dir, and a file changes in /tmp/dir/sub, you won't see it. You must walk the directory tree and add each subdirectory yourself. Use filepath.WalkDir to find subdirectories, then call watcher.Add on each. When a new directory is created, you need to add it to the watcher. This requires handling Create events and checking if the new path is a directory.

Renames are tricky. On many systems, a rename appears as a delete event followed by a create event. If you're watching a file and it gets renamed, the watcher might stop receiving events for that path. You may need to re-add the new path. Some editors rename files when saving. They write to a temp file, then rename it over the original. This triggers a delete and create. Your handler should be robust to this pattern.

Symlinks behave differently across OS. On Linux, fsnotify watches the symlink itself. On macOS, it might follow the link. Check the documentation for your target platform if symlinks matter. If you need consistent behavior, resolve symlinks with filepath.EvalSymlinks before adding the path.

Editor noise is real. Some editors create temporary files like .swp or ~ files while you type. These trigger events. Filter by extension or check event.Op to ignore noise. You can also debounce events. If multiple events arrive in quick succession, wait a short delay before processing. This reduces redundant reloads.

Goroutine leaks happen when the goroutine waits on a channel that never gets closed. Always have a cancellation path. Use context or a done channel to signal the watcher to stop. If you forget to close the watcher, the background goroutine runs forever. This leaks memory and file descriptors.

Convention aside: _ discards a value intentionally. result, _ := ... says "I considered the second return value and chose to drop it". Use it sparingly with errors. Dropping errors is usually a bug. In a watcher, you might drop the ok value if you handle closure elsewhere, but be explicit.

Recursion is manual. Add subdirectories yourself.

When to watch and when to check

Filesystem watchers are powerful, but they aren't the right tool for every job. Use the right primitive for the job.

Use fsnotify when you need efficient, OS-level notifications for local files. Use fsnotify when building dev tools, hot-reload servers, or sync utilities that react to file changes. Use fsnotify when you want low latency and minimal CPU usage. Use a periodic check when you are monitoring a remote filesystem or network mount that does not support native events. Use a database trigger or message queue when the data source is a distributed system rather than a local disk. Use plain sequential code when you don't need concurrency: the simplest thing that works is usually the right thing.

The decision matrix is clear. Pick the tool that matches your data source.

Where to go next