Golang fsnotify: Watch File and Directory Changes in Go

Use fsnotify in Go to watch files and directories, handle create/write/remove/rename events, watch parent directories for single files, handle symlinks, debounce noisy saves, and avoid common watcher mistakes.

Published

Updated

Read time 16 min read

Reviewed byDeepak Prasad

Golang fsnotify: Watch File and Directory Changes in Go

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/fsnotify resolved from a project go.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):

go
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:

text
go mod init example.com/yourapp
go get github.com/fsnotify/fsnotify@latest

If 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:

text
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.

go
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

go
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:

text
watch the parent directory → ignore unrelated names → handle Write/Create/Rename/Remove for your target basename

If 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.

go
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:

text
receive event → normalize/filter path → test event.Has(Write|Create|...) → optionally ignore Chmod → do work

On fsnotify versions before Event.Has, use bitwise tests such as event.Op&fsnotify.Write != 0.

Example: prefer Has over comparing Op with ==

go
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

go
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:

text
walk existing tree with filepath.WalkDir → Add every directory
on Create, if the new path is a directory → Add that path too

Large 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

go
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.


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.

This sets up data.txt and linkdata.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.

go
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.

go
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.

go
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:

go
if event.Has(fsnotify.Chmod) {
	return
}

if event.Has(fsnotify.Write) || event.Has(fsnotify.Create) || event.Has(fsnotify.Rename) {
	// process content change
}

Good rule:

text
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:

text
poll path periodically
check modified time, size, or checksum
process only when value changes

Use 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:

text
WRITE  config.yaml
WRITE  config.yaml
CHMOD  config.yaml
RENAME config.yaml
CREATE config.yaml

So avoid assuming:

text
one save = one event

Use debouncing when the triggered work is expensive or should run only once.

Example debounce idea:

text
receive fsnotify event
reset short timer
wait for quiet period
process file once

This 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:

text
process opens app.log
another process deletes app.log
fsnotify may report CHMOD first
file is finally closed
fsnotify reports REMOVE

So do not rely on only one exact delete sequence.

Better handling:

go
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.

go
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.

go
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.


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 …