watchfiles
watchfiles tells your Python code when files change, using the Rust Notify library underneath so it gets native OS notifications instead of polling. The API is small: watch() is a blocking generator that yields batches of (change, path) tuples, awatch() is the asyncio version, and run_process() restarts a target function or shell command whenever watched paths change, which is the piece dev-server reloaders are built on. It ships a CLI too, so watchfiles 'pytest' src reruns a command on save. It replaced the author's earlier watchgod package.
The best default file watcher in Python today: small API, native-speed events, and run_process solves the actual problem people have. Pick watchdog only when you need its richer event model or cannot take a compiled dependency.
Use it if
- You are building a reload-on-change loop: run_process handles the restart, signal, and debounce logic you would otherwise hand-roll badly
- You need async file watching that actually fits asyncio: awatch is an async generator, no thread juggling like older watchers require
- You care about efficiency on big trees: native OS notifications through Rust mean near-zero CPU while idle, where polling watchers burn cycles
- You want sane defaults: events are debounced into batches and DefaultFilter already ignores .git, __pycache__, and editor temp files
- You need rich event semantics: changes are only added, modified, or deleted, so if you need move/rename pairing or directory-level events distinguished, watchdog's observer and event-class model gives you more
- You are on an unusual platform without a prebuilt wheel: the core is compiled Rust, so installation there means a Rust stable toolchain, which is a real cost in locked-down CI or odd architectures
- You expect hot reload: run_process kills and restarts the whole process; module-level state is gone each cycle, and anything not importable under multiprocessing spawn will not work as a function target
- Your files live on network mounts or some Docker volume setups where inotify events never arrive: watchfiles falls back to force_polling, at which point its main advantage over simpler watchers shrinks
- Your framework already bundles it: uvicorn's --reload uses watchfiles when present, so adding your own layer on top just duplicates the machinery
Setup reality
pip install watchfiles is a prebuilt binary wheel on Linux, macOS, and Windows for common architectures, with anyio as the only Python dependency; off that happy path pip compiles from source and you need Rust stable installed, which surprises people in minimal containers. Python 3.10 through 3.15 is required. The operational gotchas are environmental: inotify limits on Linux can need raising for huge trees, some Docker and all NFS-style mounts deliver no events so you must set force_polling or the WATCHFILES_FORCE_POLLING env var, and events arrive debounced in batches (1.6s window by default), which confuses people expecting one callback per save. Function targets for run_process must be importable at module top level because the child starts via spawn.
Patterns
Watch a directory for changeswatch-directory
from watchfiles import watch
for changes in watch('./src'):
print(changes)
# {(<Change.modified: 2>, '/abs/path/src/app.py')}watch blocks and yields a set per batch, not per file event; multiple rapid saves are debounced into one batch (1.6s window by default, tune with debounce=).
React differently per change typehandle-change-types
from watchfiles import watch, Change
for changes in watch('./data'):
for change, path in changes:
if change == Change.added:
ingest(path)
elif change == Change.deleted:
evict(path)Only three change kinds exist: added, modified, deleted. A rename arrives as a delete of the old path plus an add of the new one, unpaired.
Watch inside asyncioasync-watch
import asyncio
from watchfiles import awatch
async def main():
async for changes in awatch('./src', './config'):
await reload_config(changes)
asyncio.run(main())awatch is a true async generator; you can pass multiple paths positionally to any of the watch functions, files or directories.
Stop a watch from another taskstop-watching-cleanly
import threading
from watchfiles import watch
stop_event = threading.Event()
for changes in watch('./src', stop_event=stop_event):
handle(changes)
if should_quit(changes):
stop_event.set()Without a stop_event the generator only ends via KeyboardInterrupt. awatch accepts an anyio/asyncio Event instead of a threading one.
Restart a Python function on changereload-python-function
from watchfiles import run_process
def start_server(host, port):
...
if __name__ == '__main__':
run_process('./src', target=start_server, args=('0.0.0.0', 8000))The target runs in a child process started with spawn, so it must be importable at module top level; lambdas and closures fail. Each change kills and restarts the child, state included.
Restart a shell command on changereload-shell-command
from watchfiles import run_process
if __name__ == '__main__':
run_process('./site', target='hugo build', target_type='command')String targets with target_type='command' are executed as commands; leave target_type as the default 'auto' and it guesses from the target's type.
Run the reload loop from async codeasync-run-process
import asyncio
from watchfiles import arun_process
def worker():
...
async def main():
await arun_process('./src', target=worker)
asyncio.run(main())arun_process still runs the target in a separate process, not a coroutine; it just lets the supervising loop live inside asyncio.
Only react to Python filesfilter-python-files
from watchfiles import watch, PythonFilter
for changes in watch('./src', watch_filter=PythonFilter()):
run_tests()PythonFilter extends DefaultFilter, so .git, __pycache__, and editor droppings stay ignored; PythonFilter(extra_extensions=('.pyi',)) widens it.
Write a custom change filtercustom-filter
from watchfiles import Change, DefaultFilter, watch
class YamlAdded(DefaultFilter):
def __call__(self, change: Change, path: str) -> bool:
return (
super().__call__(change, path)
and change == Change.added
and path.endswith(('.yml', '.yaml'))
)
for changes in watch('./configs', watch_filter=YamlAdded()):
load_new(changes)Any callable (change, path) -> bool works as watch_filter; subclassing DefaultFilter keeps the standard ignore list for free.
Rerun a command on save from the shellcli-rerun-command
# rerun tests when src or tests change
watchfiles 'pytest -x' src tests
# restart a module, filtering to python files
watchfiles --filter python 'python -m myapp' srcThe whole command must be one quoted argument. The CLI is handy glue, but if you are reloading uvicorn specifically, its own --reload already uses watchfiles.
Make watching work on mounts without eventsforce-polling-docker-nfs
import os
from watchfiles import watch
# option 1: environment, also respected by the CLI and uvicorn
# WATCHFILES_FORCE_POLLING=true
# option 2: explicit
for changes in watch('/mnt/shared', force_polling=True, poll_delay_ms=500):
sync(changes)NFS and some Docker volume drivers never deliver inotify events, so the watch just sits silent; polling is the fix and the env var is the least invasive way to apply it in containers.
Alternatives
| Package | Registry | Pick it when |
|---|---|---|
| watchdog | PyPI | You want pure-Python portability, per-event callbacks, and finer event classes (moves, directory events) and can accept its threads-and-observers API. |
| hupper | PyPI | You only want in-development process reload for a server and prefer a tool built for exactly that, as used by Pyramid's pserve. |