| Tested on | RHEL 10.2 with Go 1.26.5 |
|---|---|
| Package | go 1.26.5github.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:
go mod init watchdemogo 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):
go get github.com/fsnotify/fsnotify@v1.10.1The 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:
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)
}
}
}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.
go run .The program blocks and prints a ready line when the watch is active:
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:
touch example.txt && echo hello >example.txt && rm example.txtIllustrative event lines (your OS or editor may add extra Write or Chmod events, or reorder them):
CREATE example.txt
WRITE example.txt
REMOVE example.txtTreat 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:
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:
- Watch the parent directory with
watcher.Add(filepath.Dir(target)). - Compare
filepath.Clean(event.Name)to the cleaned target path. - React to
Create,Write, orRenameon 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:
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:
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:
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"orstrings.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).
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.

