Every language ecosystem has at least one file watcher. Node projects use nodemon. Rust projects use cargo-watch. Python projects have watchdog. Go developers reach for air. The problem with all of these is that they’re ecosystem-specific — you need a different tool for every stack you work with, and they each have subtly different behaviours around edge cases that matter.
watchexec is a single, fast file watcher written in Rust that works with any language and any command. Once you understand how it handles debouncing, signals, and filtering, you’ll reach for it first regardless of what you’re building.
Why the Implementation Details Matter
A file watcher sounds trivial — run a command when a file changes. But the hard part is everything around that:
Debouncing: saving a file in most editors produces a burst of filesystem events (write, sync, attribute change, write again). A naive watcher runs your command four times. You want to wait for the burst to settle before triggering.
Process management: if your command is still running when the next change arrives, do you kill the old process or wait for it? If you kill it, what signal? Some processes need SIGTERM to clean up, not SIGKILL.
What counts as a change: do you watch only the files you explicitly name, or everything in a directory? Do you respect .gitignore? What about generated files in dist/ or target/ that your build command just created?
Shell vs no-shell execution: running your command through a shell means you can use pipes and redirects, but it adds latency and means signals don’t propagate cleanly to child processes.
watchexec gets all of these right by default, with configuration options when the defaults don’t fit.
Basic Usage
watchexec -- cargo test
watchexec -e rs -- cargo test
watchexec -w src/ -- npm run build
watchexec --clear -- pytest tests/
-- separates watchexec’s arguments from the command to run. The -e flag filters by extension. -w scopes watching to a specific directory. --clear clears the terminal before each run.
By default, watchexec watches the current directory recursively, respects .gitignore patterns, and ignores hidden files and common build output directories. Most of the time you don’t need to configure filtering at all.
Signal Handling
When a new change arrives while the previous command is still running, watchexec’s default behaviour is to send SIGTERM to the running process, wait for it to exit, then start a fresh run. You can control this with --on-busy-update:
# Kill immediately (SIGKILL after SIGTERM timeout)
watchexec --on-busy-update restart -- ./server
# Queue the next run, let current one finish
watchexec --on-busy-update queue -- make test
# Do nothing — ignore events while busy
watchexec --on-busy-update do-nothing -- slow-build.sh
For long-running processes like development servers, restart is usually right. For test suites where you don’t want to lose results, queue means you always see the output of the most recent complete run.
The signal sent on restart defaults to SIGTERM, which gives processes a chance to clean up. If you have a process that doesn’t respond well to SIGTERM, you can configure the signal with --signal.
Filtering
watchexec uses a layered filtering system:
# Only watch .go files
watchexec -e go -- go test ./...
# Exclude a directory
watchexec --ignore node_modules/ -- tsc --watch
# Watch only specific files
watchexec -w src/main.rs -w src/lib.rs -- cargo build
# Combine: watch src/, only .py files, exclude __pycache__
watchexec -w src/ -e py --ignore '__pycache__' -- python -m pytest
For projects with a .gitignore, you usually don’t need explicit exclusions — watchexec’s gitignore integration handles them. The --no-vcs-ignore flag disables this if you’re working outside a git repository or need to watch generated files.
Practical Recipes
Running tests on save (Rust):
watchexec -e rs --clear -- cargo test
TypeScript compilation with error output:
watchexec -w src/ -e ts,tsx -- npx tsc --noEmit
Restarting a Python development server:
watchexec -e py -w app/ --on-busy-update restart -- python -m uvicorn app.main:app --port 8000
Running a linter on changed files only (using the $WATCHEXEC_COMMON_PATH variable):
watchexec exposes environment variables about what changed: $WATCHEXEC_CREATED_PATH, $WATCHEXEC_WRITTEN_PATH, $WATCHEXEC_REMOVED_PATH. These are colon-separated lists of paths. You can use them to run commands only on changed files:
watchexec -e js -- bash -c 'eslint $WATCHEXEC_WRITTEN_PATH'
Multiple commands:
watchexec -- bash -c 'make build && make test'
Running through bash gives you the full shell, including && for conditional execution. This is fine for most use cases. If you need precise signal propagation, prefer single commands.
watchexec vs the Ecosystem-Specific Alternatives
vs nodemon: nodemon is JavaScript-specific and requires Node.js. watchexec has no runtime dependencies (it’s a single binary) and is faster. If you’re running non-JavaScript commands in a Node project, watchexec is cleaner.
vs cargo-watch: cargo-watch wraps watchexec internally. You can use watchexec directly and save yourself the intermediate tool. The cargo-specific argument handling (cargo watch -x test) isn’t meaningfully more convenient than watchexec -- cargo test.
vs entr: entr is the UNIX-philosophy option — reads a list of files from stdin, very composable. watchexec is easier for the common case (watch a directory) and has better signal handling.
vs the built-in watchers (Jest’s --watch, pytest-watch, etc.): built-in watchers are often more integrated — Jest’s watch mode understands test dependencies and only reruns affected tests. watchexec doesn’t have that intelligence. For language-specific test running, the native tool may be better. For everything else, watchexec.
Installation
# cargo
cargo install watchexec-cli
# Homebrew (macOS/Linux)
brew install watchexec
# Nix
nix-env -iA nixpkgs.watchexec
# Winget (Windows)
winget install watchexec
The binary is watchexec. No daemon, no configuration file required to get started.
When to Use a Configuration File
For project-specific watching setups, watchexec supports a watchexec.toml (or in package.json under a watchexec key for JavaScript projects). This is useful when you have a complex filtering setup that you want to share with a team:
[default]
paths = ["src/", "tests/"]
extensions = ["py"]
ignore = ["__pycache__", "*.pyc"]
clear = true
on-busy-update = "queue"
Most personal dev workflows don’t need this — a shell alias or a Taskfile/Justfile entry is simpler. But for a shared project where everyone should be watching the same things, a committed watchexec.toml is cleaner than hoping everyone remembers the right flags.
The actual value of a file watcher is measured in the time between saving a file and seeing feedback. watchexec’s startup time is fast enough that it essentially vanishes from that calculation. The bottleneck becomes whatever command you’re running — which is as it should be.