This guide is for Go developers who need filesystem notifications: config reload, dev watchers, upload folders, or build triggers. The community package github.com/fsnotify/fsnotify wraps OS APIs (inotify on Linux, kqueue on macOS and BSD, ReadDirectoryChangesW on Windows, FEN on illumos) so your program can react to creates, writes, deletes, renames, and permission changes without polling. It is not part of the standard library; you add it with go get. The sections below move from a minimal flow to directory and single-file patterns, event.Has handling, recursion limits, symlinks, debouncing, and mistakes that show up only after the first demo works.
Tested on: 64-bit Linux with Go and
github.com/fsnotify/fsnotifyresolved from a projectgo.mod.
Quick answer: start a watcher in Go
Call fsnotify.NewWatcher(), defer Close(), Add a directory (or path your OS supports), then loop with select on watcher.Events and watcher.Errors. For one file you care about, watch its parent directory and compare Event.Name to your file after normalizing paths. fsnotify does not watch subdirectories recursively unless you Add each directory yourself.
Minimal program shape (run inside a module after go get; swap dir for a real path and keep the process running with a signal or context as in the full example at the end):
package main
import (
"log"
"github.com/fsnotify/fsnotify"
)
func main() {
watcher, err := fsnotify.NewWatcher()
if err != nil {
log.Fatal(err)
}
defer watcher.Close()
if err := watcher.Add("/path/to/dir"); err != nil {
log.Fatal(err)
}
// In production, exit this loop on context.Done() or os.Signal; here it
// runs until the watcher is closed or the process is interrupted.
for {
select {
case e, ok := <-watcher.Events:
if !ok {
return
}
log.Println("event:", e.Name, e.Op)
case err, ok := <-watcher.Errors:
if !ok {
return
}
log.Println("watcher error:", err)
}
}
}Keep the process alive (for example block on a signal or context cancellation); otherwise the program exits before events arrive.
What is fsnotify in Go?
fsnotify is an event-driven library: the kernel pushes change notifications instead of your code stat-ing files in a tight loop. Typical uses include hot reload in development, reloading yaml or json config on change, watching an ingest directory, or triggering rebuilds.
Supported platforms and backends
| OS family | Backend (typical) |
|---|---|
| Linux | inotify |
| macOS, BSD | kqueue |
| Windows | ReadDirectoryChangesW |
| illumos | FEN |
Cross-platform does not mean identical: event ordering, coalescing, and edge cases (especially around rename, remove, and symlinks) vary. Read the upstream README and FAQ when Linux and laptop macOS disagree. Virtual and remote filesystems are a separate problem (see Common mistakes).
Install fsnotify in Go
From your module root:
go mod init example.com/yourapp
go get github.com/fsnotify/fsnotify@latestIf the module already exists, run only go get.
Import github.com/fsnotify/fsnotify. Pin a version in production go.mod so upgrades do not change behavior under load. Latest releases may require a newer Go toolchain than older projects; if go get complains, upgrade Go or choose an older fsnotify minor that matches your policy. The Event.Has helper exists from fsnotify v1.7 onward; on older versions use (event.Op & fsnotify.Write) != 0 style checks.
Basic fsnotify watcher flow
Lifecycle in order:
NewWatcher → Add paths → loop on Events and Errors → Close when shutting down| API | Role |
|---|---|
fsnotify.NewWatcher() |
Allocates a watcher tied to an OS handle |
watcher.Add(path) |
Subscribes a file or directory path |
watcher.Events |
Channel of fsnotify.Event (path + Op bitmask) |
watcher.Errors |
Channel of errors from the watcher |
watcher.Close() |
Stops watches and closes channels |
Always read both Events and Errors in the same loop (commonly select). If you never receive from Errors, some failures can stall or hide problems. The package examples follow that pattern.
Example: short-lived demo with a temp directory
This program creates a temp dir, adds a watch, writes one file, prints at least one event, then exits. Run with go run . after go get.
package main
import (
"fmt"
"log"
"os"
"path/filepath"
"time"
"github.com/fsnotify/fsnotify"
)
func main() {
dir, err := os.MkdirTemp("", "fsn-*")
if err != nil {
log.Fatal(err)
}
defer os.RemoveAll(dir)
w, err := fsnotify.NewWatcher()
if err != nil {
log.Fatal(err)
}
defer w.Close()
if err := w.Add(dir); err != nil {
log.Fatal(err)
}
go func() {
for {
select {
case e, ok := <-w.Events:
if !ok {
return
}
fmt.Println("event:", filepath.Base(e.Name), e.Op)
case err, ok := <-w.Errors:
if !ok {
return
}
log.Println("error:", err)
}
}
}()
path := filepath.Join(dir, "note.txt")
if err := os.WriteFile(path, []byte("hello"), 0o644); err != nil {
log.Fatal(err)
}
time.Sleep(200 * time.Millisecond)
}You should see a line such as event: note.txt with a CREATE or WRITE style Op (exact bitmask varies by OS). This program was checked with go run on 64-bit Linux after go get github.com/fsnotify/fsnotify@latest (fsnotify v1.10.x in that run).
Watch a directory for file changes
Add on a directory reports activity for entries in that directory, not magically for every nested path (see Recursive watching). You usually branch on the kind of change.
Create, write, remove, rename, and chmod
Op combines flags. Prefer event.Has(fsnotify.Write) (and the same for Create, Remove, Rename, Chmod) instead of event.Op == fsnotify.Write, because one kernel notification can include multiple bits. That difference is what people often mean by “fsnotify event handling else case”: a long if / else on a single Op misses combined flags; Has keeps logic correct.
| Operation | Typical meaning |
|---|---|
Create |
New name inside the watched directory |
Write |
Content changed (also used for some saves) |
Remove |
Name removed |
Rename |
Name changed within the watched area (details vary by OS) |
Chmod |
Metadata or mode changed |
Filter by name or extension
Normalize paths with filepath.Clean or filepath.EvalSymlinks when you compare names, then filter by base name, suffix, or a set of allowed files. Skip editor scratch files (*.swp, .#file, etc.) if they spam your handler.
Example: only react to .go files under a watched directory
package main
import (
"log"
"path/filepath"
"strings"
"github.com/fsnotify/fsnotify"
)
func wantGoFile(ev fsnotify.Event) bool {
if filepath.Ext(ev.Name) != ".go" {
return false
}
base := filepath.Base(ev.Name)
if strings.HasPrefix(base, ".#") || strings.HasSuffix(base, "~") {
return false
}
return ev.Has(fsnotify.Write) || ev.Has(fsnotify.Create) || ev.Has(fsnotify.Rename)
}
func logGoChanges(w *fsnotify.Watcher) {
for {
select {
case err, ok := <-w.Errors:
if !ok {
return
}
log.Println("watcher:", err)
case ev, ok := <-w.Events:
if !ok {
return
}
if !wantGoFile(ev) {
continue
}
log.Println("go change:", ev.Name, ev.Op)
}
}
}
// Stub so this file builds standalone; replace with your watcher wiring.
func main() {}Wire logGoChanges from a goroutine after w.Add on your project root (mind watch limits on large trees).
Watch a single file correctly
You can Add a file path, but editors often save atomically: write a temp file, then rename it over the original. The inode you watched may disappear, and the watch can stop doing what you expect.
Better pattern:
watch the parent directory → ignore unrelated names → handle Write/Create/Rename/Remove for your target basenameIf the file is removed and recreated, a directory watch still receives the new create/write events; a watch tied only to the old inode cannot. The upstream FAQ recommends this approach explicitly.
Example: watch the parent directory for app.yaml
Add the directory that contains the file, then compare filepath.Base(filepath.Clean(ev.Name)) to your target name and treat Write, Create, and Rename as reasons to re-read the file.
package main
import (
"fmt"
"log"
"os"
"path/filepath"
"github.com/fsnotify/fsnotify"
)
const configName = "app.yaml"
func main() {
dir, err := os.MkdirTemp("", "cfg-*")
if err != nil {
log.Fatal(err)
}
defer os.RemoveAll(dir)
cfg := filepath.Join(dir, configName)
if err := os.WriteFile(cfg, []byte("v: 1\n"), 0o644); err != nil {
log.Fatal(err)
}
w, err := fsnotify.NewWatcher()
if err != nil {
log.Fatal(err)
}
defer w.Close()
if err := w.Add(dir); err != nil {
log.Fatal(err)
}
match := func(name string) bool {
return filepath.Base(filepath.Clean(name)) == configName
}
seen := make(chan string, 4)
go func() {
for {
select {
case err, ok := <-w.Errors:
if !ok {
return
}
log.Println("watcher:", err)
case ev, ok := <-w.Events:
if !ok {
return
}
if !match(ev.Name) {
continue
}
if ev.Has(fsnotify.Write) || ev.Has(fsnotify.Create) || ev.Has(fsnotify.Rename) {
seen <- fmt.Sprintf("%s %v", ev.Name, ev.Op)
}
}
}
}()
if err := os.WriteFile(cfg, []byte("v: 2\n"), 0o644); err != nil {
log.Fatal(err)
}
fmt.Println(<-seen)
w.Close()
}You should see one line naming app.yaml with a write-related Op.
fsnotify event handling with Event.Has
Treat each event as: which path (event.Name), which operations (event.Has(...)), then decide work. One save can produce several events close together; your handler can still use Has for each.
Recommended shape:
receive event → normalize/filter path → test event.Has(Write|Create|...) → optionally ignore Chmod → do workOn fsnotify versions before Event.Has, use bitwise tests such as event.Op&fsnotify.Write != 0.
Example: prefer Has over comparing Op with ==
package main
import "github.com/fsnotify/fsnotify"
func handle(ev fsnotify.Event) {
// Fragile: misses combined flags such as Create|Write.
if ev.Op == fsnotify.Write {
// ...
}
// Preferred on v1.7+:
if ev.Has(fsnotify.Write) {
// ...
}
// Equivalent on older releases:
if ev.Op&fsnotify.Write != 0 {
// ...
}
}
func main() {}Example: switch with Has and a safe default
package main
import (
"log"
"github.com/fsnotify/fsnotify"
)
func logEvent(ev fsnotify.Event) {
switch {
case ev.Has(fsnotify.Create):
log.Println("create", ev.Name)
case ev.Has(fsnotify.Write):
log.Println("write", ev.Name)
case ev.Has(fsnotify.Remove):
log.Println("remove", ev.Name)
case ev.Has(fsnotify.Rename):
log.Println("rename", ev.Name)
case ev.Has(fsnotify.Chmod):
// Often ignored for content reloads
default:
log.Println("other", ev.Name, ev.Op)
}
}
func main() {}Watch subdirectories recursively
fsnotify does not recurse by default. Add("/project") covers immediate children of /project, not /project/sub/file unless /project/sub is also watched.
Practical approach:
walk existing tree with filepath.WalkDir → Add every directory
on Create, if the new path is a directory → Add that path tooLarge trees consume watches: on Linux, fs.inotify.max_user_watches and max_user_instances can be hit (too many open files / no space left on device). On kqueue platforms each watch can imply open file descriptors, so ulimit -n matters sooner.
Example: add every existing directory, then extend on Create
package main
import (
"io/fs"
"log"
"os"
"path/filepath"
"github.com/fsnotify/fsnotify"
)
func addAllDirs(w *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 w.Add(path)
})
}
func maybeWatchNewDir(w *fsnotify.Watcher, ev fsnotify.Event) {
if !ev.Has(fsnotify.Create) {
return
}
st, err := os.Stat(ev.Name)
if err != nil || !st.IsDir() {
return
}
if err := w.Add(ev.Name); err != nil {
log.Println("add subdir:", err)
}
}
func main() {}Call addAllDirs once at startup, then from your event loop call maybeWatchNewDir(w, ev) for each event.
fsnotify and symlinks
You pass a path string to Add. Whether you are watching the symlink node or the ultimate target depends on the path you pass and the OS. Writes that open the target by a different path may not show up as writes on the symlink entry. If a symlink is replaced to point elsewhere, a watch on the old target does not automatically follow the new target.
Rules of thumb:
| Goal | Approach |
|---|---|
| Detect changes to real content | Watch the resolved directory or file you care about |
| Detect replacement of a symlink entry | Watch the parent directory of the symlink and filter its name |
| Mixed setups | Validate on Linux, macOS, or Windows as appropriate |
See also symbolic links on Linux. Do not assume identical semantics across backends.
Example: same parent directory for a file and a symlink
This sets up data.txt and link → data.txt, adds a watch on the parent, prints matching events for a short window, then closes the watcher. Writes through the symlink name can differ by OS—use this as a starting point for your own tests.
package main
import (
"fmt"
"log"
"os"
"path/filepath"
"time"
"github.com/fsnotify/fsnotify"
)
func main() {
dir, err := os.MkdirTemp("", "sym-*")
if err != nil {
log.Fatal(err)
}
defer os.RemoveAll(dir)
target := filepath.Join(dir, "data.txt")
if err := os.WriteFile(target, []byte("a"), 0o644); err != nil {
log.Fatal(err)
}
link := filepath.Join(dir, "link")
if err := os.Symlink("data.txt", link); err != nil {
log.Fatal(err)
}
w, err := fsnotify.NewWatcher()
if err != nil {
log.Fatal(err)
}
defer w.Close()
if err := w.Add(dir); err != nil {
log.Fatal(err)
}
go func() {
for {
select {
case err, ok := <-w.Errors:
if !ok {
return
}
log.Println(err)
case ev, ok := <-w.Events:
if !ok {
return
}
base := filepath.Base(ev.Name)
if base != "data.txt" && base != "link" {
continue
}
fmt.Println(ev.Name, ev.Op)
}
}
}()
if err := os.WriteFile(target, []byte("b"), 0o644); err != nil {
log.Fatal(err)
}
time.Sleep(400 * time.Millisecond)
}You should see at least one event line for data.txt (and possibly link, depending on backend). Try an additional write opening link on your machine to compare paths.
Debounce fsnotify events
One user action can emit bursts: chunked writes, temp files, metadata updates, indexer or antivirus Chmod spam, and backend-specific splitting. For reload or rebuild triggers, reset a short timer on relevant events and run your handler once things go quiet (for example with time.AfterFunc and a mutex, or a small debounce helper). Debouncing also helps large copies finish before you read the file.
Example: debounce with time.AfterFunc
Each matching event resets the timer; reload runs only after debounce elapses with no new matching events.
package main
import (
"sync"
"time"
"github.com/fsnotify/fsnotify"
)
type reloadDebouncer struct {
mu sync.Mutex
wait time.Duration
timer *time.Timer
reload func(path string)
pending string
}
func (d *reloadDebouncer) schedule(path string) {
d.mu.Lock()
defer d.mu.Unlock()
d.pending = path
if d.timer != nil {
d.timer.Stop()
}
d.timer = time.AfterFunc(d.wait, func() {
d.mu.Lock()
p := d.pending
d.mu.Unlock()
if d.reload != nil && p != "" {
d.reload(p)
}
})
}
func example(ev fsnotify.Event, d *reloadDebouncer) {
if !ev.Has(fsnotify.Write) && !ev.Has(fsnotify.Create) && !ev.Has(fsnotify.Rename) {
return
}
d.schedule(ev.Name)
}
func main() {}Set wait to something like 150 * time.Millisecond for editor saves; tune per workload.
Common fsnotify mistakes
Watching only the file, not the parent directory
Atomic saves replace the file; watch the parent and filter Event.Name.
Assuming recursion
Add each subdirectory you need, including new dirs on Create.
Ignoring watcher.Errors
Always select on Errors alongside Events. Ranging only over w.Events is risky: errors may not be observed and some failure modes are harder to debug.
package main
import (
"log"
"github.com/fsnotify/fsnotify"
)
func watchLoop(w *fsnotify.Watcher) {
for {
select {
case ev, ok := <-w.Events:
if !ok {
return
}
_ = ev // handle path + Op
case err, ok := <-w.Errors:
if !ok {
return
}
log.Println("watcher:", err)
}
}
}
func main() {}Treating every Chmod as a content change
Chmod events are often noisy and do not always mean the file content changed. They can be triggered by permission changes, metadata updates, editor behavior, backup tools, antivirus/indexing tools, or OS-specific filesystem behavior.
If your application only cares about file content, usually handle Write, Create, Rename, and Remove, and ignore Chmod.
Example event filter:
if event.Has(fsnotify.Chmod) {
return
}
if event.Has(fsnotify.Write) || event.Has(fsnotify.Create) || event.Has(fsnotify.Rename) {
// process content change
}Good rule:
If you only care about file content, do not treat Chmod as a reload trigger.Expecting NFS, SMB, FUSE, /proc, or /sys to behave like local disk
fsnotify depends on the underlying operating system’s filesystem notification support. Remote filesystems, virtual filesystems, and pseudo-filesystems may not emit reliable events.
The project FAQ specifically calls out weak or missing notification support for filesystems such as NFS, SMB, FUSE, /proc, and /sys.
For these paths, a better approach may be:
poll path periodically
↓
check modified time, size, or checksum
↓
process only when value changesUse polling or an application-level event source when the filesystem cannot provide reliable notifications.
Expecting one save to produce one event
A single save operation can produce multiple events. Some editors write to a temporary file, rename it over the original file, update permissions, or trigger several write notifications.
Example event burst from one save:
WRITE config.yaml
WRITE config.yaml
CHMOD config.yaml
RENAME config.yaml
CREATE config.yamlSo avoid assuming:
one save = one eventUse debouncing when the triggered work is expensive or should run only once.
Example debounce idea:
receive fsnotify event
↓
reset short timer
↓
wait for quiet period
↓
process file onceThis is useful for config reloads, rebuilds, file sync, and service restart workflows.
Linux remove quirks
On Linux, deleting a file that is still open can produce surprising events. You may see a Chmod event before the final Remove event because the underlying inotify behavior reports changes while the file still has open handles.
Example sequence:
process opens app.log
another process deletes app.log
fsnotify may report CHMOD first
file is finally closed
fsnotify reports REMOVESo do not rely on only one exact delete sequence.
Better handling:
if event.Has(fsnotify.Remove) || event.Has(fsnotify.Rename) {
// treat as deleted or replaced
}If you are watching a specific file, prefer watching the parent directory and filtering by Event.Name. This helps detect when the file is removed and later recreated.
FIFO (named pipe) caveats
You can Add a FIFO, but idle pipes may produce few events until reader and writer connect. A common dev pattern is pairing with a reader such as tail -f. For FIFO basics in Go, see FIFO in Go.
Example: register a named pipe (Linux)
syscall.Mkfifo is Unix-specific; skip this pattern on Windows builds.
package main
import (
"path/filepath"
"syscall"
"github.com/fsnotify/fsnotify"
)
func addFifoWatch(w *fsnotify.Watcher, dir, name string) error {
fifo := filepath.Join(dir, name)
if err := syscall.Mkfifo(fifo, 0o600); err != nil {
return err
}
return w.Add(fifo)
}
func main() {}After Mkfifo, open ends for read/write in another goroutine or process if you need steady Write events.
Go fsnotify cheat sheet
| Goal | Pattern |
|---|---|
| Watch one directory | watcher.Add(dir) |
| Watch one file reliably | Add(parentDir) + filter filepath.Base(event.Name) |
| Detect content writes | event.Has(fsnotify.Write) (often with Create/Rename) |
| New file in dir | event.Has(fsnotify.Create) |
| Deleted file | event.Has(fsnotify.Remove) |
| Renamed within watch | event.Has(fsnotify.Rename) |
| Ignore metadata noise | Skip Chmod unless needed |
| Recursive tree | Walk dirs at start + Add on new directories |
| Bursty saves | Debounce |
| Clean shutdown | Close() watcher; unblock loop with context or signals |
Which pattern should you use?
| Requirement | Start here |
|---|---|
| Simple local directory | Single Add on the directory |
| Config file reload | Parent directory + basename filter + debounce |
| Entire project tree | Recursive Add with watch limits in mind |
| Symlink-heavy layout | Decide link vs target per OS tests |
| Network share | Prefer polling or server-side hooks |
Full example: directory watch with shutdown
This example uses a temp directory so you can run it locally after go mod init and go get. It listens for interrupt, reads both channels, and uses event.Has in a switch.
package main
import (
"context"
"fmt"
"log"
"os"
"os/signal"
"path/filepath"
"github.com/fsnotify/fsnotify"
)
func main() {
w, err := fsnotify.NewWatcher()
if err != nil {
log.Fatal(err)
}
defer w.Close()
dir, err := os.MkdirTemp("", "watch-*")
if err != nil {
log.Fatal(err)
}
defer os.RemoveAll(dir)
if err := w.Add(dir); err != nil {
log.Fatal(err)
}
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt)
defer stop()
go func() {
for {
select {
case <-ctx.Done():
return
case werr, ok := <-w.Errors:
if !ok {
return
}
log.Println("watcher error:", werr)
case ev, ok := <-w.Events:
if !ok {
return
}
switch {
case ev.Has(fsnotify.Create):
fmt.Println("create:", ev.Name)
case ev.Has(fsnotify.Write):
fmt.Println("write:", ev.Name)
case ev.Has(fsnotify.Remove):
fmt.Println("remove:", ev.Name)
case ev.Has(fsnotify.Rename):
fmt.Println("rename:", ev.Name)
case ev.Has(fsnotify.Chmod):
fmt.Println("chmod:", ev.Name)
default:
fmt.Println("other:", ev.Name, ev.Op)
}
}
}
}()
testfile := filepath.Join(dir, "demo.txt")
if err := os.WriteFile(testfile, []byte("hello"), 0o644); err != nil {
log.Fatal(err)
}
<-ctx.Done()
fmt.Println("exiting")
}Run locally; you should see create and write lines for demo.txt, then clean exit on Ctrl+C. The default branch in the inner switch logs unexpected Op combinations—do not confuse that with a default on the outer select, which would busy-spin.
Summary
fsnotify gives Go programs OS-backed file and directory notifications via NewWatcher, Add, and a loop over Events and Errors. Treat Op as a bitmask with event.Has, watch parent directories when a single file must survive atomic editor saves, and add watches per subdirectory because recursion is not automatic. Symlink and rename semantics vary by platform; debounce when saves are chatty; ignore Chmod unless you truly need it; and plan fallbacks where the OS does not deliver reliable notifications. The misspelling fsontify still refers to github.com/fsnotify/fsnotify.

