Watching File Changes with watchfiles
Introduction
The watchfiles library makes watching file changes in Python straightforward, detecting modifications to files and directories in real time. It wraps platform-specific file system APIs (like inotify on Linux, FSEvents on macOS, and ReadDirectoryChangesW on Windows) to provide a unified interface across operating systems.
Use cases for watchfiles include:
- Automatically restarting a development server when source files change
- Triggering tests when test files are modified
- Live reloading a web application during development
- Watching configuration files and reloading settings on change
The library requires Python 3.9 or later and supports Python versions up to 3.14. Install it with pip:
pip install watchfiles
Version 1.1.1 is the current stable release. Once installed, you can start watching files immediately with the library’s simplest API. The fundamental building block, watch(), yields change events in real time as files are created, modified, or deleted in the target directory.
Getting started with basic file watching
The core of watchfiles is the watch() function, which yields change events as you modify files. Each event contains a Change enum indicating what happened to a file.
from watchfiles import watch, Change
# Watch the current directory for changes
for changes in watch("."):
for change_type, path in changes:
if change_type == Change.added:
print(f"File added: {path}")
elif change_type == Change.modified:
print(f"File modified: {path}")
elif change_type == Change.deleted:
print(f"File deleted: {path}")
When you run this script, watchfiles registers with the operating system’s native file notification API and blocks until a change occurs. Creating, modifying, or deleting a file in the watched directory produces a set of FileChange tuples, each pairing a Change enum value with the affected path string. The output below captures a typical sequence where a single file was first created, then modified, then deleted:
# output
File added: /path/to/example.py
File modified: /path/to/example.py
File deleted: /path/to/example.py
The Change enum has three values:
Change.added(integer value 1) — a new file was createdChange.modified(integer value 2) — an existing file was changedChange.deleted(integer value 3) — a file was removed
The watch() function returns an iterator that blocks until changes occur. Each iteration yields a set of FileChange tuples in the format (Change, str) — the change type first, then the path string.
Async Watching with awatch()
For asynchronous applications, watchfiles provides awatch(), which works with asyncio. This is useful when you’re building async web servers or running watchers alongside other async tasks.
import asyncio
from watchfiles import awatch, Change
async def main():
async for changes in awatch("."):
for change_type, path in changes:
print(f"{change_type.name}: {path}")
asyncio.run(main())
The async iterator suspends the coroutine between change batches instead of blocking the thread, so other async tasks can run concurrently while waiting for file events. Running this example and modifying a few files in the watched directory produces output like the following, where each change type is printed alongside the affected path:
# output
modified: /path/to/config.json
added: /path/to/new_file.py
deleted: /path/to/old_file.txt
The async version produces the same change events as the synchronous one but integrates with the asyncio event loop, so it does not block other coroutines while waiting for file system events. Use awatch() when you need to coordinate file watching with other async operations like serving HTTP requests or processing task queues concurrently.
Filtering Files
By default, watchfiles watches all files in the specified directories. You can filter which files to watch using filter classes or custom callables.
DefaultFilter
DefaultFilter excludes common files that usually shouldn’t trigger watches:
from watchfiles import watch, DefaultFilter
# Use the default filter (ignores __pycache__, .git, .venv, etc.)
for changes in watch(".", watch_filter=DefaultFilter):
print(changes)
The DefaultFilter skips editor swap files, version control directories like .git, Python bytecode caches in pycache, and virtual environment folders. Running the watcher with this filter surfaces only the events you actually care about: modifications to real source files:
# output
{Change.modified: '/path/to/app.py'}
By default, the filter excludes editor swap files, version control metadata, Python bytecode caches, and virtual environment directories. Cutting out that noise keeps your development workflow focused on actual source file changes. These built-in exclusions cover the most common development artifacts, but if you need more specific filtering, watchfiles lets you target individual file extensions or write custom logic.
PythonFilter
PythonFilter specifically watches .py files, ignoring everything else:
from watchfiles import watch, PythonFilter
# Only watch Python files
for changes in watch(".", watch_filter=PythonFilter):
print(changes)
Running this with PythonFilter active limits notifications to files with the .py extension, so changes to README files, JSON configs, or images are silently ignored. This is particularly useful in Python project directories where you want to restart a development server on source changes but not on unrelated file activity. The output only surfaces the files that match:
# output
{Change.modified: '/path/to/main.py'}
For cases that fall between the default filter and the Python-specific filter, watchfiles lets you define your own filtering logic with a plain Python function. This gives you complete control over which files trigger events.
Custom Filters
You can pass any callable that accepts a Change enum and path string, returning a boolean:
from watchfiles import watch, Change
def my_filter(change: Change, path: str) -> bool:
# Only watch files ending in .txt or .md
return path.endswith(".txt") or path.endswith(".md")
for changes in watch("src/", watch_filter=my_filter):
print(changes)
A custom filter function receives the change type and file path for every event the watcher detects and returns True to include it or False to ignore it. The example above restricts notifications to files ending in .txt or .md, so you can focus exclusively on documentation changes while edits to source code, configuration files, and binary assets pass through silently:
# output
{Change.modified: '/path/to/src/readme.md'}
Filter callables receive the Change enum value and the path string (not a Path object) and should return True to watch the file or False to ignore it. This interface is intentionally simple so you can plug in your own logic without learning a DSL or subclassing anything.
Configuration Parameters
watchfiles provides several parameters to control watching behavior:
from watchfiles import watch, DefaultFilter
for changes in watch(
".",
debounce=2000, # milliseconds to wait before yielding (default 1600)
recursive=True, # watch subdirectories (default True)
watch_filter=DefaultFilter, # file filter (default None)
grace_period=5.0, # seconds to wait after startup (default 0)
force_polling=False, # use polling instead of native APIs (default False)
):
print(changes)
Calling watch() with these parameters produces change events from the working directory, waiting 2 full seconds after each event to let rapid editing settle before yielding. The recursive flag ensures nested folders are monitored, and the grace period gives your application time to initialize before any accumulated startup changes are reported. Here is the output you would see after saving app.py:
# output
{Change.modified: '/path/to/app.py'}
| Parameter | Default | Description |
|---|---|---|
debounce | 1600 | Milliseconds to wait after the last change before yielding |
recursive | True | Whether to watch subdirectories |
watch_filter | None | Filter function or class to exclude files |
grace_period | 0 | Seconds to wait after process starts before watching for changes |
force_polling | False | Use polling instead of native file system events |
The debounce parameter consolidates rapid successive changes into a single event. If you save a file twice within 1600ms (the default), watchfiles reports only one change. Increase debounce if you have editors that save multiple times during auto-save.
Hot Reloading with run_process()
For automatic process restarting, watchfiles provides run_process(). It runs a command and restarts it whenever watched files change:
from watchfiles import run_process
# Run a development server and restart on file changes
run_process("python main.py")
When you save a source file in the watched directory, run_process() terminates the old process and launches a fresh instance automatically. The terminal output captures this lifecycle — each file change triggers a restart notice followed by the application’s standard startup messages. Below is what you would see after modifying app.py in a Flask development server monitored by run_process():
# output
INFO: Running on http://127.0.0.1:5000
INFO: Restarting due to changes...
INFO: Running on http://127.0.0.1:5000
This blocks until you press Ctrl+C. The function accepts the same watch parameters as the lower-level API, letting you set which directories to monitor, which files to filter, and the debounce interval. Here is an example that watches a specific directory with a custom filter and adjusted timing:
from watchfiles import run_process, DefaultFilter
run_process(
".",
target="python -m flask run",
watch_filter=DefaultFilter,
debounce=1000,
)
This example targets a Flask development server, passing both the working directory and the command as separate arguments. The watch_filter parameter applies the same default exclusions as before, and the reduced debounce of 1000ms means restarts happen faster after each save. Running this configuration produces:
# output
INFO: Watching directory '.' for changes...
INFO: Restarting due to changes...
INFO: Running on http://127.0.0.1:5000
The paths come before the target parameter. The process stdout and stderr stream to your terminal so you can see the application’s output in real time. This synchronous approach works well for traditional scripts, but async applications need an equivalent that integrates with the asyncio event loop without blocking.
Async hot reloading with arun_process()
For async applications, arun_process() provides the same restart-on-change behavior in an async context. It runs a command and restarts it on file changes, just like its synchronous counterpart, but fits naturally into an asyncio-based application:
import asyncio
from watchfiles import arun_process
async def main():
await arun_process("python app.py")
asyncio.run(main())
The async variant accepts the same paths and target parameters as run_process() but returns an awaitable coroutine. Running this example starts the specified Python application, and any subsequent file change in the watched directory triggers a restart. The terminal output looks identical to the synchronous version:
# output
Application started
Restarting due to changes...
Application started
This works identically to run_process() but integrates with asyncio, so you can run it alongside other async tasks in the same event loop. Use it when your main application is async and you need to coordinate process restarting with concurrent operations like WebSocket connections or background job processing.
Common Gotchas
WSL Requires force_polling
If you’re running watchfiles on Windows Subsystem for Linux (WSL), native file system events may not work correctly. The inotify mechanism that Linux normally uses does not always function properly across the WSL boundary. Enable polling explicitly to switch to a periodic scan that works reliably in this environment:
from watchfiles import watch
for changes in watch(".", force_polling=True):
print(changes)
Polling bypasses the operating system’s native event notification system and instead periodically scans the filesystem for changes. This approach guarantees compatibility with WSL where the inotify-based Linux event API does not reliably cross the Windows-Linux boundary. Saving a file while this watcher is active produces:
# output
{Change.modified: '/path/to/file.py'}
KeyboardInterrupt Behavior
When you press Ctrl+C to stop watch() or awatch(), the iterator raises KeyboardInterrupt as the normal mechanism for signal-based interruption. If you’re running in a loop without a try/except handler, this exception terminates your program without any cleanup message. Wrapping the watch loop in a try/except block lets you shut down gracefully:
from watchfiles import watch, Change
try:
for changes in watch("."):
print(changes)
except KeyboardInterrupt:
print("Watcher stopped")
When the user presses Ctrl+C, the interrupt signal propagates through the event loop and the KeyboardInterrupt exception is caught by the try/except handler. The except block runs the cleanup message before the program exits cleanly. Running this example and interrupting it with Ctrl+C produces:
# output
{Change.modified: '/path/to/app.py'}
Watcher stopped
This is expected behavior, not an error. Any cleanup code you place after the except block will run after the watcher stops. The debounce feature is another behavior worth understanding. It controls how watchfiles consolidates rapid successive changes into a single event batch. Here is what happens when you save a file multiple times within the debounce window:
Debounce consolidates events
The debounce parameter helps avoid triggering on every keystroke during file editing, but it also means changes within the debounce window get grouped together:
# If you save a file 5 times within 1 second, you get 1 event
for changes in watch(".", debounce=1600):
print(f"Got {len(changes)} changes")
Running this example and saving a file five times in rapid succession produces only a single event batch. The debounce timer resets on each change, so watchfiles waits until 1600ms of silence before reporting all the accumulated changes together as one notification:
# output
Got 5 changes
Adjust debounce based on how your editor saves files. Higher values reduce events but add latency.
State not preserved between restarts
When run_process() restarts your application, it kills the old process and starts a fresh one. Any in-memory state is lost. This is typically fine for development but means you need to handle state persistence (databases, caches) separately if needed in production.
See also
- Working with subprocess in Python
- Async-await patterns in Python
- Error handling best practices
- Logging best practices in Python
watchfiles provides a straightforward way to react to file system changes in Python. Whether you need simple change detection with watch(), async watching with awatch(), or full hot reloading with run_process(), the library handles the platform-specific details so you can focus on your application logic.