watchdog
watchdog watches directories and tells your Python code when files are created, modified, moved, deleted, opened, or closed. It hides the platform differences behind one API: inotify on Linux, FSEvents or kqueue on macOS, ReadDirectoryChangesW on Windows, and a snapshot-diff polling fallback everywhere else. You subclass FileSystemEventHandler, register it with an Observer against a path, start the observer, and your on_created and on_modified methods get called from a background thread. It also ships watchmedo, a CLI that runs shell commands or auto-restarts a process when matching files change, which is how most people first meet it: as the thing making their dev server reload.
The default answer for cross-platform file watching in Python, and the reason your dev server reloads. Budget time for debouncing duplicate events yourself, and check watchfiles first if you are Linux-only and CPU on a big tree matters.
Use it if
- You need one file-watching API that behaves the same on Linux, macOS, and Windows without writing three backends and three sets of tests
- You are building a dev-mode reloader, a hot-compile step, or an inbox folder that picks up files dropped by another process
- You want the watchmedo CLI so a shell command or an auto-restart wrapper can be wired up from a Makefile without any Python code
- You need to watch a network share, a CIFS mount, or a Docker bind mount where native OS events do not fire, and you want to swap in PollingObserver with a one-line import change
- You want event types beyond create and modify, including moved with both source and destination paths, plus opened and closed on Linux
- You only target Linux and want raw speed: watchfiles wraps a Rust notify backend and does its own coalescing, so it burns far less CPU on large trees than watchdog's pure-Python inotify reader
- You watch huge recursive trees on Linux: inotify needs one watch per subdirectory, and blowing past /proc/sys/fs/inotify/max_user_watches gets you OSError(ENOSPC, 'inotify watch limit reached') that only a sysctl change fixes
- You cannot tolerate duplicate events: a single editor save routinely fires several modified events plus a create and a move, because editors write to a temp file and rename it, and watchdog does no debouncing for you
- You use Vim or any editor that swaps files in rather than writing in place: the README warns on-modified will not fire for those files unless you reconfigure the editor
- You are on BSD or macOS kqueue with many files: kqueue holds one file descriptor per watched file, so the README tells you to raise ulimit -n, and it does not scale to deep trees
- You need the latest fixes from a release: 6.0.0 shipped 2024-11-01 and the 7.0.0-dev section of the changelog has been collecting breaking pattern-matching changes and new follow_symlink support ever since, none of it on PyPI yet
- Your handler does slow work: events are dispatched serially from one background thread, so a slow on_modified stalls the queue and you start losing the ability to keep up with the filesystem
Setup reality
pip install watchdog pulls nothing on Linux and Windows and needs no compiler because wheels ship for CPython and PyPy on Python 3.9 and up. The watchmedo CLI is behind an extra, so it is pip install 'watchdog[watchmedo]' if you want the shell-command and auto-restart subcommands, which adds PyYAML. Building from source on macOS wants XCode. The real friction is behavioral, not installation. Since 5.0.0 the core enforces keyword-only arguments, so observer.schedule(handler, path, True) raises a TypeError and you have to write recursive=True. Observer is chosen for you at import time based on the platform, so the same code silently uses different backends with different event granularity, and on CIFS or a network mount you get no events at all until you import PollingObserver by hand. Everything runs on a background thread, so exceptions in your handler go nowhere useful unless you catch and log them yourself, and observer.join() after stop() is what actually waits for shutdown.
Patterns
Watch a directory tree for any changewatch-directory
import time
from watchdog.events import FileSystemEvent, FileSystemEventHandler
from watchdog.observers import Observer
class Handler(FileSystemEventHandler):
def on_any_event(self, event: FileSystemEvent) -> None:
print(event.event_type, event.src_path, event.is_directory)
observer = Observer()
observer.schedule(Handler(), '.', recursive=True)
observer.start()
try:
while True:
time.sleep(1)
finally:
observer.stop()
observer.join()recursive must be passed by keyword since 5.0.0; a positional third argument raises TypeError. observer.stop() only signals the thread, so join() is what actually waits for it to finish.
Handle create, modify, move, and delete separatelyhandle-specific-events
from watchdog.events import (
DirModifiedEvent, FileModifiedEvent,
FileCreatedEvent, FileDeletedEvent, FileMovedEvent,
FileSystemEventHandler,
)
class Handler(FileSystemEventHandler):
def on_created(self, event: FileCreatedEvent) -> None:
print('created', event.src_path)
def on_modified(self, event: DirModifiedEvent | FileModifiedEvent) -> None:
if not event.is_directory:
print('modified', event.src_path)
def on_moved(self, event: FileMovedEvent) -> None:
print('moved', event.src_path, '->', event.dest_path)
def on_deleted(self, event: FileDeletedEvent) -> None:
print('deleted', event.src_path)dest_path is only populated on moved events; it is an empty string everywhere else. Writing a file inside a watched directory also fires DirModifiedEvent for the parent, which is why the is_directory guard matters.
Only react to certain file patternsfilter-by-pattern
from watchdog.events import PatternMatchingEventHandler
handler = PatternMatchingEventHandler(
patterns=['*.py', '*.toml'],
ignore_patterns=['*/.git/*', '*/__pycache__/*', '*.pyc'],
ignore_directories=True,
case_sensitive=False,
)
handler.on_modified = lambda event: print('reload', event.src_path)Every argument here is keyword-only. In 6.0.0 matching uses PurePath.match(), so '*.py' matches any depth; the unreleased 7.0.0 line switches to full_match() and those patterns will have to become '**/*.py'.
Subscribe to only the event classes you care aboutfilter-event-types
from watchdog.events import FileClosedEvent, FileCreatedEvent
from watchdog.observers import Observer
observer = Observer()
observer.schedule(
handler,
'/var/spool/inbox',
recursive=False,
event_filter=[FileCreatedEvent, FileClosedEvent],
)event_filter drops unwanted events at the emitter, so it is cheaper than filtering in your handler. FileClosedEvent is only emitted by the inotify backend, so this exact filter yields nothing on macOS or Windows.
Collapse the burst of events from one savedebounce-duplicate-events
import threading
from watchdog.events import FileSystemEvent, FileSystemEventHandler
class Debounced(FileSystemEventHandler):
def __init__(self, callback, delay: float = 0.3):
self._callback = callback
self._delay = delay
self._timers: dict[str, threading.Timer] = {}
self._lock = threading.Lock()
def on_any_event(self, event: FileSystemEvent) -> None:
if event.is_directory:
return
path = str(event.src_path)
with self._lock:
existing = self._timers.pop(path, None)
if existing:
existing.cancel()
timer = threading.Timer(self._delay, self._fire, args=(path,))
self._timers[path] = timer
timer.start()
def _fire(self, path: str) -> None:
with self._lock:
self._timers.pop(path, None)
self._callback(path)watchdog does no coalescing at all. One editor save commonly produces a created, several modified, and a moved event because editors write a temp file and rename it, so a debounce like this is not optional in real use.
Watch a network share or bind mountpolling-observer
from watchdog.observers.polling import PollingObserver
observer = PollingObserver(timeout=2) # stat the tree every 2 seconds
observer.schedule(handler, '/mnt/share', recursive=True)
observer.start()Native events do not cross CIFS, NFS, or many Docker bind mounts, so the default Observer stays silent there. PollingObserver walks and stats the whole tree on every tick, so the cost scales with file count, not with change count.
Compare two points in time instead of streaming eventsdirectory-snapshot-diff
from watchdog.utils.dirsnapshot import DirectorySnapshot, DirectorySnapshotDiff
before = DirectorySnapshot('/data')
run_the_build()
after = DirectorySnapshot('/data')
diff = DirectorySnapshotDiff(before, after)
print(diff.files_created)
print(diff.files_modified)
print(diff.files_moved) # list of (src, dest) tuples
print(diff.dirs_deleted)This is the machinery PollingObserver uses, and it is useful on its own when you only need to know what changed between two moments and do not want a background thread at all.
Fail usefully when the inotify watch limit is hithandle-inotify-limits
import errno
from watchdog.observers import Observer
observer = Observer()
observer.schedule(handler, '/big/tree', recursive=True)
try:
observer.start()
except OSError as exc:
if exc.errno == errno.ENOSPC:
raise SystemExit(
'inotify watch limit reached. Raise it with:\n'
' sudo sysctl fs.inotify.max_user_watches=524288'
) from exc
raiseLinux allocates one inotify watch per subdirectory in a recursive watch. watchdog raises OSError(ENOSPC, 'inotify watch limit reached') and OSError(EMFILE, 'inotify instance limit reached'); neither can be fixed from inside your process.
Feed events into an asyncio event loopbridge-to-asyncio
import asyncio
from watchdog.events import FileSystemEvent, FileSystemEventHandler
from watchdog.observers import Observer
class QueueHandler(FileSystemEventHandler):
def __init__(self, loop: asyncio.AbstractEventLoop, queue: asyncio.Queue):
self._loop = loop
self._queue = queue
def on_any_event(self, event: FileSystemEvent) -> None:
self._loop.call_soon_threadsafe(self._queue.put_nowait, event)
async def main():
loop = asyncio.get_running_loop()
queue: asyncio.Queue = asyncio.Queue()
observer = Observer()
observer.schedule(QueueHandler(loop, queue), '.', recursive=True)
observer.start()
try:
while True:
event = await queue.get()
print(event)
finally:
observer.stop()
observer.join()Handlers run on watchdog's own thread, so touching loop objects directly from them is a data race. call_soon_threadsafe is the only supported hand-off, and queue.put_nowait keeps the watcher thread from blocking on a slow consumer.
Add and remove watches while runningunschedule-watch
watch = observer.schedule(handler, '/tmp/project', recursive=True)
# attach a second handler to the same watch
observer.add_handler_for_watch(other_handler, watch)
# later
observer.unschedule(watch)
# or drop everything
observer.unschedule_all()schedule() returns an ObservedWatch whose identity is (path, recursive, event_filter). Scheduling the same triple twice reuses one emitter and just adds a handler, so keep the returned object if you ever plan to unschedule.
Stop a handler exception from silently killing dispatchcatch-handler-errors
import logging
from watchdog.events import FileSystemEvent, FileSystemEventHandler
log = logging.getLogger(__name__)
class SafeHandler(FileSystemEventHandler):
def on_any_event(self, event: FileSystemEvent) -> None:
try:
self.handle(event)
except Exception:
log.exception('handler failed for %s', event.src_path)
def handle(self, event: FileSystemEvent) -> None:
...Events are dispatched from a background thread, so an uncaught exception there does not reach your main thread and does not set a non-zero exit code. Wrap the body or you will debug a watcher that appears to work but quietly processes nothing.
Run a command on change without writing Pythonwatchmedo-cli
$ pip install 'watchdog[watchmedo]'
$ watchmedo shell-command \
--patterns='**/*.py' \
--ignore-patterns='**/.git/*' \
--recursive \
--ignore-directories \
--command='pytest -q' \
.
# restart a long-running process instead of firing and forgetting
$ watchmedo auto-restart --patterns='**/*.py' --recursive -- python app.pywatchmedo lives behind the [watchmedo] extra and pulls in PyYAML; a plain pip install watchdog gives you the library but no CLI. shell-command runs your command once per event, so pair it with --ignore-directories or you will get several runs per save.
Alternatives
| Package | Registry | Pick it when |
|---|---|---|
| watchfiles | PyPI | You want a faster Rust-backed watcher with built-in event coalescing and both sync and async iterators, and you do not need watchdog's handler-class API |
| pyinotify | PyPI | You are Linux-only and want direct inotify access, accepting that the project has not seen a release in years |
| inotify_simple | PyPI | You want a thin Linux-only ctypes binding over inotify with no threads or abstractions, and will handle recursion and coalescing yourself |