Golang fsnotify: Watch File and Directory Changes in Go

Tested on RHEL 10.2 with Go 1.26.5
Package go 1.26.5
github.com/fsnotify/fsnotify v1.10.1
Applies to Any host with Go 1.23+ installed
Privilege Normal user
Scope Practical Go fsnotify tutorial: install the package, watch a directory, handle Create/Write/Remove/Rename/Chmod events with Event.Has, watch one config file via its parent directory, add recursive watches, and filter or debounce noisy saves. Does not cover FIFO pipes, symlink internals, or full Watcher API reference.
Related guides Golang context
Parse YAML in Go
Golang Viper
Getting started with Go
Remove a Go module dependency

github.com/fsnotify/fsnotify is the usual Go file watcher: your program subscribes to a path and reads filesystem events from channels instead of polling. Typical uses include config reload, dev hot-reload, upload folders, and build triggers. Under the hood it maps to inotify on Linux, kqueue on macOS and BSD, ReadDirectoryChangesW on Windows, and FEN on illumos.

This walkthrough installs fsnotify v1.10.1, runs a directory watcher you can copy into a module, then covers event flags, reliable single-file watching, recursive directories, filtering, debouncing, and the problems that only show up after the first demo works.


Install fsnotify and watch a directory

Add fsnotify inside an existing Go module. If you do not have one yet, create it first:

bash
go mod init watchdemo

go mod init writes a new go.mod in the current directory.

Pull the current stable release (v1.10.x needs Go 1.23 or newer):

bash
go get github.com/fsnotify/fsnotify@v1.10.1

The module line in go.mod and the added lines confirm fsnotify and its golang.org/x/sys dependency resolved.

Run the watcher

A minimal Go file watcher creates a Watcher, subscribes with Add, then loops on Events and Errors until you stop the process. Always read watcher.Errors; backend problems surface there instead of on Events.

Save this as main.go in your module:

go
package main

import (
	"fmt"
	"log"
	"os"
	"os/signal"
	"syscall"

	"github.com/fsnotify/fsnotify"
)

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

	watchDir := "."
	if err := watcher.Add(watchDir); err != nil {
		log.Fatal(err)
	}
	fmt.Printf("Watching %s (edit files in another terminal)\n", watchDir)

	sig := make(chan os.Signal, 1)
	signal.Notify(sig, syscall.SIGINT, syscall.SIGTERM)

	for {
		select {
		case <-sig:
			return
		case err, ok := <-watcher.Errors:
			if !ok {
				return
			}
			log.Println("error:", err)
		case event, ok := <-watcher.Events:
			if !ok {
				return
			}
			fmt.Printf("%-6s %s\n", event.Op, event.Name)
		}
	}
}
Output

Run it with go run . from the directory you want to observe (or change watchDir to another existing path). Watcher.Add requires the path to exist; it is not recursive.

bash
go run .

The program blocks and prints a ready line when the watch is active:

output
Watching . (edit files in another terminal)

Example output

From a second shell in the same directory, touch, write, and remove a file while the watcher runs:

bash
touch example.txt && echo hello >example.txt && rm example.txt

Illustrative event lines (your OS or editor may add extra Write or Chmod events, or reorder them):

text
CREATE example.txt
WRITE  example.txt
REMOVE example.txt

Treat sample output as illustrative, not a guaranteed sequence. A Write event also does not prove every byte has been flushed to disk yet—reload logic may need a short delay or debouncing (covered later).


Handle fsnotify Create, Write, Remove, Rename, and Chmod events

Each fsnotify.Event carries Name (path) and Op (a bitmask of what happened). Prefer event.Has(fsnotify.Write) over event.Op == fsnotify.Write, because one notification can include multiple bits. Event.Has() has been available since fsnotify v1.6.0.

Event Meaning
fsnotify.Create A file or directory was created
fsnotify.Write Content was written
fsnotify.Remove The path was removed
fsnotify.Rename The path was renamed or moved
fsnotify.Chmod Metadata or permissions changed

Use independent if event.Has(...) checks when you care about more than one operation on the same event:

go
for {
	select {
	case event, ok := <-watcher.Events:
		if !ok {
			return
		}
		if event.Has(fsnotify.Create) {
			log.Println("created:", event.Name)
		}
		if event.Has(fsnotify.Write) {
			log.Println("written:", event.Name)
		}
		if event.Has(fsnotify.Remove) || event.Has(fsnotify.Rename) {
			log.Println("gone or moved:", event.Name)
		}
		if event.Has(fsnotify.Chmod) {
			log.Println("metadata changed:", event.Name)
		}
	case err, ok := <-watcher.Errors:
		if !ok {
			return
		}
		log.Println("watcher error:", err)
	}
}

Ignore most Chmod traffic unless attribute changes matter to your app. For content reloads, Create, Write, and Rename are usually the signals worth acting on.


Watch a specific file with fsnotify

You can call watcher.Add("/path/to/app.yaml") on a single file, but many editors save by writing a temporary file and renaming it over the original. The inode your watch pointed at may disappear, and events stop matching what you expect.

The reliable pattern for one important file—especially a config you reload on change—is:

  1. Watch the parent directory with watcher.Add(filepath.Dir(target)).
  2. Compare filepath.Clean(event.Name) to the cleaned target path.
  3. React to Create, Write, or Rename on that name.

You cannot add a watch to a path that does not exist yet. Watch the parent and wait for a Create (or later Write) on the filename you care about.

Watch the parent directory and filter Event.Name

Reuse the watcher setup from the first example (NewWatcher, defer Close, and the select on Events and Errors). Subscribe to the parent directory instead of the file itself, then filter inside the Events branch:

go
target := filepath.Clean("app.yaml")
if err := watcher.Add(filepath.Dir(target)); err != nil {
	log.Fatal(err)
}

// Inside case event, ok := <-watcher.Events:
if filepath.Clean(event.Name) != target {
	continue
}
if event.Has(fsnotify.Create) || event.Has(fsnotify.Write) || event.Has(fsnotify.Rename) {
	reloadConfig()
}

A single echo into app.yaml can trigger several reload calls because the shell and filesystem may emit separate Create and Write operations. That is normal; combine with debouncing if you reload expensive work.


Watch subdirectories recursively

watcher.Add("/project") watches only that directory. Changes under /project/subdir do not arrive unless you also Add each subdirectory. fsnotify has no built-in recursion.

Add existing directories with filepath.WalkDir

Walk the tree at startup and call Add on every directory. You need io/fs for fs.DirEntry:

go
import (
	"io/fs"
	"path/filepath"

	"github.com/fsnotify/fsnotify"
)

func addDir(watcher *fsnotify.Watcher, root string) error {
	return filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error {
		if err != nil {
			return err
		}
		if !d.IsDir() {
			return nil
		}
		return watcher.Add(path)
	})
}

Call addDir(watcher, root) once after NewWatcher() before you enter the event loop.

Watch directories created later

A startup walk misses folders created while your program runs. When you see fsnotify.Create, check whether the new path is a directory and walk it the same way—not only the top folder, but any nested directories inside a moved tree:

go
if event.Has(fsnotify.Create) {
	if info, err := os.Stat(event.Name); err == nil && info.IsDir() {
		if err := addDir(watcher, event.Name); err != nil {
			log.Println("add:", err)
		}
	}
}

Creating newdir/ after startup registers watches on that folder and any subdirectories it already contains. Very large trees can hit OS watch limits; on Linux you may see no space left on device or too many open files when fs.inotify.max_user_watches or fs.inotify.max_user_instances is exhausted.


Filter and debounce fsnotify events

Filter by file name or extension

Narrow events in the loop before doing work:

  • Exact file: filepath.Clean(event.Name) == filepath.Clean("app.yaml")
  • Extension: filepath.Ext(event.Name) == ".go" or strings.HasSuffix(event.Name, ".yaml")

Skip hidden paths or build artifacts the same way if your tree is noisy.

Debounce duplicate events

One logical save from an IDE may produce a burst such as Write, Write, Chmod, or Rename plus Create. Debouncing means waiting briefly after the latest relevant event before running expensive work (rebuild, config reload, virus scan).

go
import "time"

var debounceTimer *time.Timer
const debounceDelay = 200 * time.Millisecond

func scheduleReload(fn func()) {
	if debounceTimer != nil {
		debounceTimer.Stop()
	}
	debounceTimer = time.AfterFunc(debounceDelay, fn)
}

// Inside your Events case, after filtering to the paths you care about:
if event.Has(fsnotify.Write) || event.Has(fsnotify.Create) || event.Has(fsnotify.Rename) {
	scheduleReload(func() { reloadConfig() })
}

Tune debounceDelay for your workload. Do not treat every Chmod as a content change unless permissions matter to your logic.


Common fsnotify problems

Problem Cause / solution
Watch stops after editing a file Editor replaced the file atomically; watch the parent directory and filter Event.Name
Subdirectory changes are missing Add() is not recursive; walk existing dirs with addDir and call addDir again on new directories from Create
File does not exist yet Add() needs an existing path; watch the parent and handle Create for the filename
Many events for one save Normal editor and OS behavior; filter by name or extension and debounce
Lots of Chmod events Usually safe to ignore unless metadata changes matter
no space left on device while adding watches Linux inotify watch limit reached; reduce watched paths or raise fs.inotify.max_user_watches
Works locally but not on NFS/SMB/FUSE Remote or fused filesystems often lack reliable notification support
No events from /proc or /sys Pseudo-filesystems do not behave like normal local disks
Symlink surprises Platform behavior varies; watch the parent directory when replacement of the link itself matters

On Linux, deleting an open file may emit Chmod before Remove because of inotify semantics. That is expected and does not require a separate handler if you already treat Remove.


Summary

You can build a working Go file watcher with three pieces: fsnotify.NewWatcher(), Add on an existing directory, and a select loop that drains both Events and Errors. Inspect operations with event.Has() because Op is a bitmask, and treat Write as a hint rather than a guarantee that the file is finished changing.

When one file matters—app.yaml, .env, or a TLS bundle—watch its parent directory and match event.Name instead of attaching the watch directly to a path editors may replace. For a whole tree, walk directories at startup with filepath.WalkDir, call Add on each folder, and call addDir again when Create reports a new directory (including nested folders inside a moved tree). Noisy saves are normal; filter by name or extension and debounce before reloading config or kicking off builds.

For production services, combine the watcher with context cancellation so shutdown closes the watcher cleanly, and test on the same filesystem type you deploy to—network mounts are a common reason watchers look fine on a laptop but silent in staging.


References

Deepak Prasad

R&D Engineer

Founder of GoLinuxCloud with more than 15 years of expertise in Linux, Python, Go, Laravel, DevOps, Kubernetes, Git, Shell scripting, OpenShift, AWS, Networking, and Security. With extensive experience, he excels across development, DevOps, networking, and security, delivering robust and efficient solutions for diverse projects.

  • Go (programming language)
  • Python (programming language)
  • DevOps
  • Computer Security
  • Cloud Computing
  • Kubernetes
  • Linux
  • Ansible (software)