How to Watch for File Changes in Rust (notify crate)

Watch for file changes in Rust using the notify crate to trigger callbacks on events.

When the filesystem moves, you need to know

You are building a development server that reloads when you save a file. Or a config parser that updates without restarting. You hit save in your editor, and the program needs to react immediately. Polling the filesystem every second is wasteful, slow, and drains battery on laptops. You need the operating system to tell you when something changes.

Rust does not include file watching in the standard library. The ecosystem standard is the notify crate. It wraps the native OS APIs: inotify on Linux, kqueue on macOS and BSD, ReadDirectoryChangesW on Windows. You give notify a path, and it hands you a Watcher. The watcher sits in the background, talking to the kernel. When a file changes, the OS pokes the watcher, and the watcher calls your callback function.

Think of the filesystem like a building. You do not want to walk around checking every room every minute. You want a guard who calls you when someone touches a door. notify is that guard. You register the doors you care about, and the guard handles the rest.

The minimal watcher

The entry point is RecommendedWatcher. It selects the best backend for your current operating system. You create it with a closure that handles events, and a Config object.

use notify::{Config, Event, RecommendedWatcher, Result, Watcher};

/// Watches a directory and prints changes to stdout.
fn main() -> Result<()> {
    // RecommendedWatcher picks the optimal backend for this OS.
    let mut watcher = RecommendedWatcher::new(
        |res: Result<Event>| {
            // The callback runs on a background thread.
            if let Ok(event) = res {
                println!("Changed: {:?}", event.paths);
            }
        },
        Config::default(),
    )?;

    // Register the path. RecursiveMode::Recursive watches subdirectories too.
    watcher.watch("./src", notify::RecursiveMode::Recursive)?;

    // Keep the main thread alive. If main exits, the watcher drops and stops.
    loop {
        std::thread::sleep(std::time::Duration::from_secs(1));
    }
}

Add notify = "6.0" to your Cargo.toml. The crate version matters; the API stabilized significantly in 6.0.

The closure receives a Result<Event>. The Result handles errors that occur inside the watcher thread, such as a permission denied error or a watch descriptor limit. The Event contains the details: which paths changed, what kind of change occurred, and optional metadata.

Convention aside: RecommendedWatcher is the community default. Do not reach for InotifyWatcher or KqueueWatcher unless you need platform-specific flags that notify does not expose. Stick to RecommendedWatcher for portable code.

How the watcher lives and dies

The Watcher trait requires the watcher instance to stay alive. The watcher holds the connection to the OS kernel. If the watcher variable goes out of scope, the connection closes and events stop.

This trips up many developers. You create the watcher, register a path, and then the function returns. The program exits. Or you move the watcher into a thread and forget to join it. The watch vanishes.

Keep the watcher in a variable that lives as long as you need the watch. In a simple binary, a loop in main works. In a larger application, store the watcher in your application state struct.

The compiler helps here. If you try to use the watcher after moving it, you get E0382 (use of moved value). If you try to share the watcher across threads without synchronization, you get E0277 (trait bound not satisfied) because RecommendedWatcher does not implement Send or Sync by default. The watcher is not thread-safe. You cannot call watch from multiple threads simultaneously.

Treat the watcher as a resource. Manage its lifetime explicitly. If the watcher drops, the watch is gone.

Realistic example: reloading configuration

A common use case is reloading configuration when a file changes. You need to share state between the callback thread and the main thread. The callback runs on a background thread, so you cannot capture a mutable reference to your config. You need thread-safe sharing.

use notify::{Config, Event, RecommendedWatcher, Result, Watcher};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;

/// Watches a config file and signals the main thread to reload.
fn main() -> Result<()> {
    // AtomicBool is thread-safe and lock-free.
    let should_reload = Arc::new(AtomicBool::new(false));
    
    // Clone the Arc to move it into the closure.
    // Convention: use Arc::clone(&var) to signal this is a reference count bump,
    // not a deep clone of the data.
    let reload_flag = Arc::clone(&should_reload);

    let mut watcher = RecommendedWatcher::new(
        move |res: Result<Event>| {
            if let Ok(event) = res {
                // Filter for modify events. Editors fire noise.
                if event.kind.is_modify() {
                    reload_flag.store(true, Ordering::SeqCst);
                }
            }
        },
        Config::default(),
    )?;

    watcher.watch("./config.toml", notify::RecursiveMode::NonRecursive)?;

    // Main thread polls the flag.
    loop {
        if should_reload.swap(false, Ordering::SeqCst) {
            println!("Config changed. Reloading...");
            // Reload logic here.
        }
        std::thread::sleep(std::time::Duration::from_millis(100));
    }
}

The closure uses move to capture reload_flag. The AtomicBool allows the callback to signal the main thread without locks. The main thread checks the flag periodically and resets it.

Convention aside: Arc::clone(&should_reload) is preferred over should_reload.clone(). Both compile and do the same thing. The explicit form signals to readers that you are cloning the smart pointer, not the inner data. It prevents confusion with deep clones.

Understanding events and noise

File systems are noisy. Saving a file in VS Code might trigger Modify, Create, and Remove events in rapid succession. Some editors delete the file and recreate it. Some trigger metadata changes. The Event struct gives you tools to filter.

The kind field is an EventKind. It categorizes the change: Modify, Create, Remove, Access, or Metadata. You can check event.kind.is_modify() to filter for content changes. You can check event.kind.is_access() to ignore read events.

The paths field is a Vec<PathBuf>. It contains the paths affected by the event. On some platforms, a single event might cover multiple files. Always iterate over paths if you need to handle each file individually.

The attrs field contains optional metadata, like the size of the change or the user who triggered it. This is platform-dependent and often empty. Do not rely on it for portable logic.

Filter early. The OS will send you noise. Your job is to ignore it. If you react to every event, your program will reload dozens of times per save. Check kind, check paths, and debounce if necessary.

Configuring the watcher

Config::default() works for most cases, but you often need tweaks. The Config struct controls watcher behavior.

use notify::{Config, RecommendedWatcher, Result, Watcher};

fn main() -> Result<()> {
    let config = Config::default()
        // Ignore events for files that exist before watching starts.
        .with_ignore_initial(true)
        // Follow symbolic links.
        .with_follow_symlinks(true);

    let mut watcher = RecommendedWatcher::new(
        |res| {
            if let Ok(event) = res {
                println!("{:?}", event);
            }
        },
        config,
    )?;

    watcher.watch("./data", notify::RecursiveMode::Recursive)?;

    loop {
        std::thread::sleep(std::time::Duration::from_secs(1));
    }
}

ignore_initial is crucial. When you start watching a directory, the watcher might emit events for all existing files. If you do not want those events, set ignore_initial(true). This is the default in notify 6.0, but be explicit if you rely on it.

follow_symlinks controls whether the watcher follows symbolic links. If you watch a directory with symlinks, and follow_symlinks is false, you get events for the symlinks themselves. If true, you get events for the targets.

Convention aside: Config methods return Self, allowing chaining. Use chaining for readability. Do not create a Config just to change one field if default() works. Only configure what you need.

Pitfalls and gotchas

The callback runs on a background thread. If your callback blocks, you lose events. The watcher thread has a queue. If the queue fills up, the crate drops events to keep up. Do not do heavy work in the callback. Do not sleep. Do not make blocking I/O calls.

If you need to do heavy work, signal another thread. Use AtomicBool, Arc<Mutex<T>>, or a channel. The callback should be fast. It should update state and return.

The callback is your hot path. Keep it fast, or you will miss events.

Another pitfall is recursive watching. RecursiveMode::Recursive watches all subdirectories. If you watch a large directory tree, you consume system resources. Each watched directory uses a kernel file descriptor. Linux has a limit on inotify watches. If you hit the limit, watch returns an error.

Check the error. If you get a "Too many open files" error, you might need to increase the system limit or reduce the scope of your watch.

Use RecursiveMode::NonRecursive when you only care about a specific file or a shallow directory. It uses fewer resources.

Counter-intuitive but true: the more you watch, the harder it is to reason about events. Recursive watches can generate bursts of events. Filter aggressively.

Decision matrix

Use RecommendedWatcher when you want the best performance on the current OS and do not mind that the backend might change between platforms. Use PollWatcher when you need a fallback for environments where kernel-level notifications are unavailable, or when you are writing a library that must behave identically everywhere regardless of OS capabilities. Use notify-debouncer-mini when you need to group rapid-fire events into a single notification, which is common for text editors that fire dozens of events per save. Reach for raw inotify or kqueue bindings only when you need low-level control that the notify abstraction does not expose.

Where to go next