A high-performance, composable file system watcher for Go, built on fsnotify. Eliminates the boilerplate of raw fsnotify usage with sensible defaults, automatic recursive watching, powerful filtering, and elegant middleware chains.
- 🎯 Zero Boilerplate — Start watching with 5 lines of code
- 🌳 Automatic Recursion — Subdirectories watched automatically, including newly created ones
- ⏱️ Smart Debouncing — Global or per-path debouncing to handle rapid file changes
- 🔍 Powerful Filtering — 13 built-in filters with AND/OR/NOT composition
- 🤖 Auto-Generated Code Detection — Filter files from sqlc, protobuf, templ, etc. via gogenfilter
- 🔗 Middleware Chains — Cross-cutting concerns (logging, recovery, metrics) via composable middleware
- 🎬 Context-Aware — Graceful shutdown with Go's
context.Context - ⚡ High Performance — Channel-based streaming, minimal allocations, race-safe
- 📦 Minimal Dependencies — Only
fsnotify(stdlib for everything else) - 🧪 Battle Tested — Comprehensive test suite with race detection
- Installation
- Quick Start
- Configuration Options
- Filters
- Middleware
- Debounce Modes
- Event Types
- Error Handling
- Advanced Usage
- Design Principles
- Examples
- License
go get github.com/larsartmann/go-filewatcherRequires Go 1.26.1 or later.
This project uses Nix Flakes for reproducible builds and development:
# Enter development shell (all tools included)
nix develop
# Or use direnv for automatic environment loading
direnv allow
# Run commands via Nix (no dev shell needed)
nix run .#check # vet + lint + test
nix run .#ci # full CI pipeline
nix run .#test # run tests with -race
nix run .#lint # run linter
nix run .#lint-fix # auto-fix lint issues
nix flake check # run all quality gates
nix build . # validate reproducible buildpackage main
import (
"context"
"fmt"
"log"
"time"
filewatcher "github.com/larsartmann/go-filewatcher/v2"
)
func main() {
// Create watcher with extensions filter and debounce
watcher, err := filewatcher.New(
[]string{"./src"},
filewatcher.WithExtensions(".go"),
filewatcher.WithDebounce(500*time.Millisecond),
filewatcher.WithIgnoreDirs("vendor", "node_modules"),
)
if err != nil {
log.Fatal(err)
}
defer watcher.Close()
// Start watching
ctx := context.Background()
events, err := watcher.Watch(ctx)
if err != nil {
log.Fatal(err)
}
// Process events
for event := range events {
fmt.Printf("%s: %s\n", event.Op, event.Path)
}
}watcher, err := filewatcher.New(
[]string{"./src"},
filewatcher.WithExtensions(".go"),
filewatcher.WithMiddleware(
filewatcher.MiddlewareRecovery(), // Recover from panics
filewatcher.MiddlewareLogging(nil), // Structured logging
),
)filter := filewatcher.FilterAnd(
filewatcher.FilterExtensions(".go"),
filewatcher.FilterNot(filewatcher.FilterIgnoreDirs("vendor")),
filewatcher.FilterOperations(filewatcher.Write, filewatcher.Create),
)
watcher, err := filewatcher.New(
[]string{"./src"},
filewatcher.WithFilter(filter),
)| Option | Description | Default |
|---|---|---|
WithDebounce(d) |
Global debounce — all events coalesced into one emission after delay | 0 (disabled) |
WithPerPathDebounce(d) |
Per-path debounce — each file debounced independently | 0 (disabled) |
WithFilter(f) |
Add a custom filter function | — |
WithExtensions(exts...) |
Only emit events for given file extensions | — |
WithIgnoreDirs(dirs...) |
Discard events from given directory names | — |
WithIgnoreHidden() |
Discard events for hidden files/dirs (dot prefix) | true (dot dirs skipped during walk) |
WithRecursive(b) |
Enable/disable recursive directory watching | true |
WithMiddleware(m...) |
Add middleware to the event processing pipeline | — |
WithErrorHandler(fn) |
Set custom error handler for watcher errors | stderr logging |
WithSkipDotDirs(skip) |
Skip directories starting with a dot during walking | true |
WithBuffer(size) |
Event channel buffer size for handling bursts | 64 |
WithOnAdd(fn) |
Callback invoked when a new path is added to the watcher | — |
Filters determine which events are emitted. Return true to keep, false to discard.
| Filter | Description |
|---|---|
FilterExtensions(exts...) |
Only files with given extensions |
FilterIgnoreExtensions(exts...) |
Exclude files with given extensions |
FilterIgnoreDirs(dirs...) |
Exclude files within given directories |
FilterIgnoreHidden() |
Exclude hidden files/directories |
FilterOperations(ops...) |
Only given operation types |
FilterNotOperations(ops...) |
Exclude given operation types |
FilterGlob(pattern) |
Match file name against glob pattern |
FilterRegex(pattern) |
Match path against regex pattern |
FilterMinSize(bytes) |
Only files ≥ given size |
| Filter | Description |
|---|---|
FilterAnd(filters...) |
All filters must pass (AND) |
FilterOr(filters...) |
At least one filter must pass (OR) |
FilterNot(filter) |
Invert the filter (NOT) |
// Only .go files, excluding vendor
filter := filewatcher.FilterAnd(
filewatcher.FilterExtensions(".go"),
filewatcher.FilterNot(filewatcher.FilterIgnoreDirs("vendor")),
)
// Either .go or .md files
goOrMd := filewatcher.FilterOr(
filewatcher.FilterExtensions(".go"),
filewatcher.FilterExtensions(".md"),
)
// Only write and create operations
writeOrCreate := filewatcher.FilterOperations(
filewatcher.Write,
filewatcher.Create,
)
// Match files by glob
logsOnly := filewatcher.FilterGlob("*.log")
// Minimum file size (1KB+)
largeFiles := filewatcher.FilterMinSize(1024)
// Complex: .go files, not in vendor, not hidden, write/create only
complexFilter := filewatcher.FilterAnd(
filewatcher.FilterExtensions(".go"),
filewatcher.FilterNot(filewatcher.FilterIgnoreDirs("vendor", "node_modules")),
filewatcher.FilterNot(filewatcher.FilterIgnoreHidden()),
filewatcher.FilterOperations(filewatcher.Write, filewatcher.Create),
)Use the FilterGeneratedCode filter to automatically exclude auto-generated Go files from events. This integrates with gogenfilter to detect files from common generators:
| Generator | Detection Pattern |
|---|---|
| sqlc | models.go, querier.go, *.sql.go |
| templ | *_templ.go |
| go-enum | *_enum.go |
| protobuf | *.pb.go, *_grpc.pb.go |
| mockgen | *_mock.go, mock_*.go |
| stringer | Content detection (// Code generated by "stringer") |
| Generic | Content detection (// Code generated by ...) |
import "github.com/LarsArtmann/gogenfilter"
// Filter all generated code types
watcher, _ := filewatcher.New("./src",
filewatcher.WithFilter(filewatcher.FilterGeneratedCode()),
)
// Filter specific generators only
watcher, _ := filewatcher.New("./src",
filewatcher.WithFilter(filewatcher.FilterGeneratedCode(
gogenfilter.FilterSQLC,
gogenfilter.FilterProtobuf,
)),
)
// Combine with other filters
filter := filewatcher.FilterAnd(
filewatcher.FilterExtensions(".go"),
filewatcher.FilterGeneratedCode(), // Exclude generated .go files
)// Only .go files, excluding generated code AND vendor
cleanFilter := filewatcher.FilterAnd(
filewatcher.FilterExtensions(".go"),
filewatcher.FilterNot(filewatcher.FilterIgnoreDirs("vendor")),
filewatcher.FilterGeneratedCode(), // Auto-excludes sqlc, protobuf, etc.
)
// Combine with other filters using FilterOr
goOrTempl := filewatcher.FilterOr(
filewatcher.FilterExtensions(".go"),
filewatcher.FilterGlob("*_templ.go"), // Explicitly watch templ files
)Middleware wraps event handlers for cross-cutting concerns. Applied in reverse order (last added runs first).
| Middleware | Description |
|---|---|
MiddlewareLogging(logger) |
Log all events with structured logging (slog) |
MiddlewareRecovery() |
Recover from panics, log stack trace |
MiddlewareRateLimit(maxEvents) |
Limit to maxEvents events per second |
MiddlewareFilter(filter) |
Filter events (same as WithFilter) |
MiddlewareOnError(handler) |
Handle errors from downstream handlers |
MiddlewareMetrics(counter) |
Count processed events by operation |
MiddlewareWriteFileLog(path) |
Write events to file for audit trail |
// Basic: logging + recovery
watcher, _ := filewatcher.New(paths,
filewatcher.WithMiddleware(
filewatcher.MiddlewareRecovery(),
filewatcher.MiddlewareLogging(nil),
),
)
// With metrics
var createCount, writeCount atomic.Int64
watcher, _ := filewatcher.New(paths,
filewatcher.WithMiddleware(
filewatcher.MiddlewareRecovery(),
filewatcher.MiddlewareLogging(nil),
filewatcher.MiddlewareMetrics(func(op filewatcher.Op) {
switch op {
case filewatcher.Create:
createCount.Add(1)
case filewatcher.Write:
writeCount.Add(1)
}
}),
),
)
// Rate limiting (max 1 event per second)
watcher, _ := filewatcher.New(paths,
filewatcher.WithMiddleware(
filewatcher.MiddlewareRateLimit(100),
),
)
// Audit logging to file
watcher, _ := filewatcher.New(paths,
filewatcher.WithMiddleware(
filewatcher.MiddlewareWriteFileLog("/var/log/filewatcher.log"),
),
)
// Custom error handling
watcher, _ := filewatcher.New(paths,
filewatcher.WithMiddleware(
filewatcher.MiddlewareOnError(func(event filewatcher.Event, err error) {
slog.Error("event processing failed",
"path", event.Path,
"error", err,
)
}),
),
)All events are coalesced into a single emission after the delay since the last event.
Use case: Build systems, test runners — you want to trigger once after a burst of changes.
// Wait 500ms after last event, then emit once
filewatcher.WithDebounce(500 * time.Millisecond)Each file path is debounced independently.
Use case: Hot reloading where each file triggers its own reload.
// Each file emits independently after 500ms since its last change
filewatcher.WithPerPathDebounce(500 * time.Millisecond)Events are emitted immediately (may cause high frequency for rapid changes).
type Event struct {
Path string // Absolute path of changed file/directory
Op Op // Operation type
Timestamp time.Time // When the event was detected
IsDir bool // True if directory, false if file
}| Op | Description |
|---|---|
Create |
File or directory created |
Write |
File modified |
Remove |
File or directory removed |
Rename |
File or directory renamed |
Note: Event priority when multiple operations occur: Create > Write > Remove > Rename.
event.String() // "CREATE /path/to/file at 2026-01-15T10:30:00Z"
event.Op.String() // "CREATE", "WRITE", "REMOVE", "RENAME"
// JSON marshaling supported
data, _ := json.Marshal(event)All errors are returned explicitly (no panics). Sentinel errors for common cases:
var (
ErrWatcherClosed = errors.New("watcher is closed")
ErrNoPaths = errors.New("at least one path is required")
ErrPathNotFound = errors.New("path not found")
ErrPathNotDir = errors.New("path is not a directory")
ErrWatcherRunning = errors.New("watcher is already running")
ErrUnknownOp = errors.New("unknown operation")
)watcher, err := filewatcher.New(paths)
if err != nil {
if errors.Is(err, filewatcher.ErrPathNotFound) {
log.Printf("Path not found: %v", err)
} else {
log.Fatalf("Failed to create watcher: %v", err)
}
}
// Set error handler for runtime errors
watcher, _ = filewatcher.New(paths,
filewatcher.WithErrorHandler(func(err error) {
slog.Error("watcher error", "error", err)
}),
)watcher, _ := filewatcher.New([]string{"./src"})
// Add paths dynamically
if err := watcher.Add("./extra"); err != nil {
log.Printf("Failed to add path: %v", err)
}
// Remove paths
if err := watcher.Remove("./src/old"); err != nil {
log.Printf("Failed to remove path: %v", err)
}
// Get currently watched paths
paths := watcher.WatchList()
// Get statistics
stats := watcher.Stats()
fmt.Printf("Watching %d paths\n", stats.WatchCount)ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
events, _ := watcher.Watch(ctx)
// Process events until context is cancelled
for event := range events {
// Handle event
}
// Channel is closed, watcher stoppedfunc MyMiddleware(next filewatcher.Handler) filewatcher.Handler {
return func(ctx context.Context, event filewatcher.Event) error {
// Before processing
start := time.Now()
err := next(ctx, event)
// After processing
duration := time.Since(start)
fmt.Printf("Processed %s in %v\n", event.Path, duration)
return err
}
}
watcher, _ := filewatcher.New(paths,
filewatcher.WithMiddleware(MyMiddleware),
)// Common directories to ignore
filewatcher.DefaultIgnoreDirs
// []string{
// ".git", ".hg", ".svn",
// "vendor", "node_modules",
// "dist", "build", "bin", "out",
// "__pycache__", ".cache",
// }Performance characteristics on Apple M2 (arm64):
| Benchmark | Operations/sec | Time/op | Allocations |
|---|---|---|---|
New/SinglePath |
53,822 | 30.9 µs | 18 allocs |
New/WithOptions |
31,879 | 34.3 µs | 28 allocs |
ConvertEvent/Create |
179,262 | 7.5 µs | 3 allocs |
ConvertEvent/Chmod |
178,305,804 | 10.8 ns | 0 allocs |
PassesFilters/Single |
26,671,284 | 61.4 ns | 0 allocs |
PassesFilters/Complex |
2,325,330 | 595 ns | 0 allocs |
BuildMiddleware/None |
7,333,308 | 302 ns | 2 allocs |
BuildMiddleware/Three |
1,000,000 | 1.37 µs | 11 allocs |
Stats/Empty |
21,545,258 | 51.0 ns | 0 allocs |
WatchList/Copy |
444,613 | 6.4 µs | 1 alloc |
Run benchmarks: nix run .#bench or go test -bench=. -benchmem
- Functional Options — Clean, extensible configuration API
- Sentinel Errors —
errors.Is()for error checking - No Panics — Explicit error handling throughout
- Context First —
context.Contextfor cancellation and timeouts - Channel Streaming — Natural Go concurrency patterns
- Middleware Chains — Composable cross-cutting concerns
- Composition — Filters and middleware compose elegantly
- Minimal Dependencies — Only
fsnotify, stdlib for rest
Runnable examples in the examples/ directory:
# Basic usage with extensions and debounce
go run ./examples/basic
# Per-path debounce (each file independently)
go run ./examples/per-path-debounce
# Middleware chain (logging, recovery, metrics)
go run ./examples/middleware
# Filter auto-generated code (sqlc, protobuf, templ, etc.)
go run ./examples/filter-generated| Example | Description |
|---|---|
| basic | Simplest usage with extensions filter and global debounce |
| per-path-debounce | Each file debounced independently |
| middleware | Logging, recovery, and metrics middleware |
| filter-generated | Exclude auto-generated Go files from events |
MIT — See LICENSE file for details.
Copyright © 2026 Lars Artmann.
Made with ❤️ for Go developers