mrkeyoor.com_
Thu 06 Aug 07:41 UTC
PyPIUtilsupdated 06 Aug 2026

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.

Verdict

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.

API stability4/5The Observer plus FileSystemEventHandler shape has been the same for a decade, but 5.0.0 forced keyword-only arguments across the core and the unreleased 7.0.0-dev changelog already switches pattern matching from path.match() to path.full_match(), which means every existing pattern like *.py has to become **/*.py
Docs3/5readthedocs has a quickstart, an API reference, and per-platform notes, and the README is honest about kqueue file descriptors, Vim, and CIFS; what is missing is the guidance people actually need on debouncing duplicate events, sizing inotify watch limits, and picking between the backends
Maintenance3/5The repo is active with pushes this week and a single dedicated maintainer merging fixes, but 6.0.0 is the newest PyPI release and it landed on 2024-11-01, so a growing pile of fixes including the generate_sub_moved_events path corruption bug sits unreleased; around 209 open issues
Ecosystem5/5Roughly 26M downloads a week and a transitive dependency of a large slice of the Python dev tooling world, from live-reload servers to doc builders; watchmedo is a recognised CLI in its own right and Stack Overflow has a dedicated python-watchdog tag

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

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
    raise

Linux 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.py

watchmedo 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

PackageRegistryPick it when
watchfilesPyPIYou 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
pyinotifyPyPIYou are Linux-only and want direct inotify access, accepting that the project has not seen a release in years
inotify_simplePyPIYou want a thin Linux-only ctypes binding over inotify with no threads or abstractions, and will handle recursion and coalescing yourself