fsnotify wraps inotify, kqueue, and ReadDirectoryChangesW behind one Go API. Here's how it works.

How fsnotify gives Go programs cross-platform file watching


Every hot-reload tool, config watcher, and build system you’ve used in Go probably depends on fsnotify. It wraps OS-specific syscalls (inotify on Linux, kqueue on macOS/FreeBSD, ReadDirectoryChangesW on Windows) into a single channel-based Go API. Lots of libraries watch files. fsnotify is interesting because it maps several different OS interfaces into one clean abstraction without making callers care which backend they are using.

One interface, three operating systems

The core type is Watcher, defined in fsnotify.go. It exposes two channels: Events and Errors. Your application reads from these channels to react to filesystem changes. Watcher also has Add, Remove, and Close methods.

The real implementation lives in backend-specific files:

  • backend_inotify.go — Linux’s inotify API
  • backend_kqueue.go — kqueue on macOS and the BSDs
  • backend_windows.go — Win32 ReadDirectoryChangesW
  • backend_fen.go — FEN on illumos
  • backend_other.go — stub for unsupported platforms

Go’s build constraint system ties it all together. Each backend_*.go file has build tags or filename conventions that tell the compiler which backend to include for the target GOOS. One Watcher type, one API surface, completely different guts per OS. If you ever need to write cross-platform Go code, this pattern is worth stealing. The context package is another good example of a single interface doing a lot of work behind the scenes.

How the Linux implementation works

The Linux path in backend_inotify.go uses the inotify family of syscalls:

  1. inotify_init creates a file descriptor.
  2. inotify_add_watch registers a path with the kernel, specifying which events to track (create, modify, delete, rename, etc.).
  3. The kernel writes event structs to that file descriptor whenever something happens.
  4. A goroutine reads from the file descriptor in a loop, parses the raw bytes into Event structs, and sends them down the Events channel.

That read loop is where the action is. It blocks on a read syscall (via Go’s syscall or unix package), then decodes the variable-length inotify_event structs from the byte buffer. Each event includes a watch descriptor, a mask of what happened, and optionally a filename.

The macOS/FreeBSD path uses kqueue instead. Kqueue works differently: you register interest in file descriptors (not paths) and poll for events using kevent. This means fsnotify has to open a file descriptor for each watched path on these platforms, which has real implications for resource limits. I find this one of the more annoying quirks of kqueue-based systems.

The Windows implementation calls ReadDirectoryChangesW through Go’s syscall package. This Win32 API is directory-based, so watching a single file means watching its parent directory and filtering events. Kind of clunky, but it works.

Using fsnotify in your code

Here’s a basic file watcher:

package main

import (
	"fmt"
	"log"

	"github.com/fsnotify/fsnotify"
)

func main() {
	watcher, err := fsnotify.NewWatcher()
	if err != nil {
		log.Fatal(err)
	}
	defer watcher.Close()

	go func() {
		for {
			select {
			case event, ok := <-watcher.Events:
				if !ok {
					return
				}
				if event.Has(fsnotify.Write) {
					fmt.Println("modified:", event.Name)
				}
			case err, ok := <-watcher.Errors:
				if !ok {
					return
				}
				fmt.Println("error:", err)
			}
		}
	}()

	err = watcher.Add("/tmp/watched")
	if err != nil {
		log.Fatal(err)
	}

	// Block forever
	select {}
}

The Event type has an Op field that’s a bitmask. You check it with Has. The operations are Create, Write, Remove, Rename, and Chmod, defined in fsnotify.go as constants.

The channel-based design fits naturally into Go’s concurrency model. Spin up the watcher in one goroutine, consume events in another, or fan out to multiple consumers. If you’ve worked with goroutines and channels before, nothing here will surprise you.

The files path.go, path_other.go, and path_windows.go handle path normalization. This is trickier than you’d think. Windows paths need long-path format and backslashes. On Unix, symlinks need resolving so the watcher tracks the real file, not just the link.

Same pattern as before: path.go has the shared logic, path_windows.go and path_other.go have the OS-specific bits.

Common pitfalls

Recursive watching isn’t built in. fsnotify watches a single directory. No subdirectories. If you need recursive watching, you walk the directory tree yourself and call Add on each subdirectory. You also need to catch Create events on directories and add watchers as they appear.

Here’s a rough approach:

package main

import (
	"log"
	"os"
	"path/filepath"

	"github.com/fsnotify/fsnotify"
)

func watchRecursive(watcher *fsnotify.Watcher, root string) error {
	return filepath.Walk(root, func(path string, info os.FileInfo, err error) error {
		if err != nil {
			return err
		}
		if info.IsDir() {
			if err := watcher.Add(path); err != nil {
				return err
			}
		}
		return nil
	})
}

func main() {
	watcher, err := fsnotify.NewWatcher()
	if err != nil {
		log.Fatal(err)
	}
	defer watcher.Close()

	if err := watchRecursive(watcher, "/tmp/project"); err != nil {
		log.Fatal(err)
	}

	for {
		select {
		case event, ok := <-watcher.Events:
			if !ok {
				return
			}
			// If a new directory was created, watch it too
			if event.Has(fsnotify.Create) {
				info, err := os.Stat(event.Name)
				if err == nil && info.IsDir() {
					_ = watcher.Add(event.Name)
				}
			}
			log.Println("event:", event)
		case err, ok := <-watcher.Errors:
			if !ok {
				return
			}
			log.Println("error:", err)
		}
	}
}

Event coalescing varies by OS. On Linux, saving a file might produce a Write followed by a Chmod. On macOS, you might get one event. Your code needs to handle multiple events for the same file gracefully. Debouncing (waiting a short period after an event before acting) is the standard fix.

Watch limits are real. Linux caps inotify watches via /proc/sys/fs/inotify/max_user_watches. macOS needs an open file descriptor per watched path, so you can hit ulimit limits fast. Keep an eye on these if you’re watching large directory trees.

Why this architecture is worth studying

fsnotify is a clean example of a Go pattern I keep coming back to: define a public API in a shared file (fsnotify.go), use build-constrained files to swap implementations per platform. The consumer never thinks about whether they’re on Linux or Windows. The Watcher type, the Event type, the channels — all identical everywhere.

This same approach works for any Go library wrapping OS-specific functionality: audio, graphics, networking, hardware access. If you’ve been using functional options to configure your Go types, combining that with platform-specific backends gives you a really clean architecture.

If you’re building anything that reacts to file changes — a dev server, a config reloader, a sync tool — fsnotify is what you’ll reach for. But spending time understanding how it maps three OS APIs into Go channels will pay off well beyond this one library.