mrkeyoor.com_
Thu 06 Aug 01:00 UTC
PyPIUtilsupdated 05 Aug 2026

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.

Verdict

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.

API stability4/5The watch/awatch/run_process surface has been steady since the 0.x rewrite from watchgod, and 1.0 in 2024 froze it; changes since have been additive options and platform work.
Docs4/5watchfiles.helpmanual.io has full API reference with defaults, a CLI page, and a migration guide from watchgod; it is reference-dense but short on operational guidance for inotify limits and container quirks, which live in issues.
Maintenance4/5Pushed July 2026 with about 40 open issues (47 counting PRs); it is Samuel Colvin's project under the Pydantic umbrella of attention, actively released, though not a large team.
Ecosystem5/5Around 100M weekly downloads because uvicorn, and through it much of the FastAPI world, uses it for reload; the Rust Notify backend is itself the standard watcher crate.

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
Skip it if

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' src

The 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

PackageRegistryPick it when
watchdogPyPIYou want pure-Python portability, per-event callbacks, and finer event classes (moves, directory events) and can accept its threads-and-observers API.
hupperPyPIYou only want in-development process reload for a server and prefer a tool built for exactly that, as used by Pyramid's pserve.