Building a Recursive File Watcher in Go using fsnotify

Building a Recursive File Watcher in Go using fsnotify

# go# fsnotify# systemdesign
Building a Recursive File Watcher in Go using fsnotifyUtkarsh Kr. Singh

In my previous blog, I talked about my project Snag and discussed questions like: What am I going...

In my previous blog, I talked about my project Snag and discussed questions like:

  • What am I going to change?
  • What features am I planning to implement?
  • How can I make the system scalable, testable, and maintainable?

But one important question naturally comes up:

“Hey Utkarsh, how are you going to detect file creation, modification, or deletion inside a directory?”

The answer lies in how the operating system handles filesystem events.

For example, Linux provides a subsystem called inotify which allows programs to monitor filesystem events such as:

  • File creation
  • File modification
  • File deletion
  • Directory changes

To interact with this subsystem in Go, we can use the package fsnotify.

fsnotify provides a simple and cross-platform way to watch filesystem events in Go.

In this article, we will build a recursive file watcher in Go using fsnotify.


Prerequisites

To follow along with this tutorial, you should have:

  • Basic knowledge of Go
  • Understanding of goroutines and channels
  • Familiarity with filesystem structures

Install the dependency:

go get github.com/fsnotify/fsnotify
Enter fullscreen mode Exit fullscreen mode

Why Recursive Watching?

One limitation of fsnotify is that it does not watch directories recursively.

This means if you only watch the root directory:

project/
 ├── main.go
 ├── config/
 │   └── config.yaml
 └── handlers/
     └── user.go
Enter fullscreen mode Exit fullscreen mode

Only changes in project/ will trigger events.

Changes inside:

  • config/
  • handlers/

will not be detected unless watchers are explicitly added to those directories.

Because of this, we must:

  1. Recursively find all directories
  2. Register watchers on each directory

That is exactly what we will implement.


Implementation

1. Collect All Directories Recursively

The first step is to gather all directories inside the project.

Since fsnotify does not support recursive watching by default, we must manually collect all directories and register them.

We will create a function called getAllDirs.

// getAllDirs recursively walks the filesystem starting
// from the given root directory and returns all directories.
// This is needed because fsnotify does not watch directories
// recursively by default.
func getAllDirs(root string) ([]string, error) {
    var dirs []string

    err := filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error {
        if err != nil {
            fmt.Printf("Error accessing path %s: %v\n", root, err)
            return nil
        }

        // Collect directories only
        if d.IsDir() {
            dirs = append(dirs, path)
        }

        return nil
    })

    if err != nil {
        return nil, err
    }

    return dirs, nil
}
Enter fullscreen mode Exit fullscreen mode

This function walks the entire directory tree and returns a slice containing all directories.


2. Start the File Watcher

Next, we create a function called startWatching which:

  • Creates a watcher
  • Registers directories
  • Listens for filesystem events
// lastEvent keeps track of the last time we processed
// an event for a specific file. This helps prevent
// duplicate events that can occur in rapid succession.
var lastEvent = make(map[string]time.Time)

// startWatching starts a filesystem watcher on the
// provided directories and listens for file events.
func startWatching(dirs []string) {
    watcher, err := fsnotify.NewWatcher()
    if err != nil {
        log.Fatal(err)
    }
    defer watcher.Close()

    // Goroutine that listens for filesystem events
    go func() {
        for {
            select {

            // File system events
            case event, ok := <-watcher.Events:
                if !ok {
                    return
                }

                // Ignore permission change events
                if event.Has(fsnotify.Chmod) {
                    continue
                }

                // Ignore temporary files (like editor backups)
                if strings.HasSuffix(event.Name, "~") {
                    continue
                }

                // Debounce events that fire too quickly
                now := time.Now()
                if t, exists := lastEvent[event.Name]; exists {
                    if now.Sub(t) < 500*time.Microsecond {
                        continue
                    }
                }
                lastEvent[event.Name] = now

                // Handle file/directory creation
                if event.Has(fsnotify.Create) {
                    isDir, err := isDirectory(event.Name)
                    if err != nil {
                        log.Fatal(err)
                    }

                    if isDir {
                        log.Println("Add watcher dynamically to new directory:", event.Name)
                    } else {
                        log.Println("Run configured command for file:", event.Name)
                    }
                }

                // Handle file modifications
                if event.Has(fsnotify.Write) {
                    log.Println("Modified file:", event.Name)
                }

            // Error handling
            case err, ok := <-watcher.Errors:
                if !ok {
                    return
                }
                log.Println("Watcher error:", err)
            }
        }
    }()

    // Register watchers for all provided directories
    for _, dir := range dirs {
        err = watcher.Add(dir)
        if err != nil {
            log.Fatal(err)
        }
    }

    // Block forever to keep the program running
    <-make(chan struct{})
}
Enter fullscreen mode Exit fullscreen mode

What this function does

  • Creates a filesystem watcher
  • Listens for events inside a goroutine
  • Filters unnecessary events
  • Handles file creation and modification
  • Uses debouncing to avoid duplicate triggers

3. Detect Whether the Event is a File or Directory

When a Create event occurs, we need to determine whether the created entity is a file or directory.

// isDirectory checks whether the provided path
// refers to a directory.
func isDirectory(path string) (bool, error) {
    info, err := os.Stat(path)
    if err != nil {
        return false, err
    }

    return info.IsDir(), nil
}
Enter fullscreen mode Exit fullscreen mode

This function simply checks filesystem metadata to determine if the path is a directory.


4. Program Entry Point

Finally, everything comes together in the main function.

// Entry point of the program.
// It gathers all directories starting from the root
// and starts the filesystem watcher.
func main() {
    root := "."

    // Collect all directories recursively
    dirs, err := getAllDirs(root)
    if err != nil {
        log.Fatalf("Failed to get directories: %v\n", err)
    }

    // Start watching filesystem changes
    startWatching(dirs)
}
Enter fullscreen mode Exit fullscreen mode

Step-by-step flow

  1. Start from the root directory
  2. Recursively collect all directories
  3. Register watchers for each directory
  4. Listen for filesystem events

How the Watcher Works

The overall workflow looks like this:

Project Directory
        ↓
Scan All Directories
        ↓
Register Watchers
        ↓
Listen for Events
        ↓
Filter / Debounce Events
        ↓
Trigger Action
Enter fullscreen mode Exit fullscreen mode

This pipeline allows us to efficiently monitor filesystem changes across the entire project.


Final Result

With this approach, we have built a recursive file watcher system.

The program can:

  • Watch a directory
  • Monitor all subdirectories
  • Detect file creation and modification events
  • Filter unnecessary filesystem noise
  • Prevent duplicate triggers

This structure can serve as the foundation for tools like:

  • Hot reload systems
  • Build automation tools
  • File synchronization utilities
  • Developer productivity tools

You can extend this watcher to trigger commands, run tests, reload servers, or automate workflows whenever files change in your project.