Fix watchdog Event Fires Before File Is Written
The folder watcher calls the processing function from on_created, and new files fail intermittently — usually the large ones, usually when they arrive over the network:
pypdf.errors.PdfReadError: EOF marker not found
zipfile.BadZipFile: File is not a zip file # .xlsx and .docx are ZIP archives
pandas.errors.EmptyDataError: No columns to parse from file
PermissionError: [WinError 32] The process cannot access the file because it is being used by another process
Reprocessing the same file a minute later works perfectly, which makes the bug look random.
Root Cause
Operating systems report file creation when the directory entry appears — at the moment the writer opens the new file, before any meaningful data is written. A scanner uploading a 4 MB PDF, a network copy of an Excel workbook, or an ERP streaming a CSV all trigger created within milliseconds of starting, followed by a series of modified events as data arrives. A handler that processes on created reads whatever is on disk at that instant: zero bytes, a truncated PDF without its %%EOF trailer, or half a ZIP archive whose central directory — written last — does not exist yet. On Windows the writer usually holds the file open without read sharing, so the read fails with a sharing violation instead. There is no portable event that means "the writer has finished": Linux reports closed for writers that close the file, macOS and Windows do not, and writers that rename a temporary file into place emit moved rather than created for the final name.
Minimal Diagnostic
Record what the handler sees at the moment each event fires, compared with the file's final size. The gap is the bug.
# pip install "watchdog>=4.0"
import threading
import time
from pathlib import Path
from watchdog.events import FileSystemEventHandler
from watchdog.observers import Observer
WATCH = Path("drop/incoming")
class Probe(FileSystemEventHandler):
def on_any_event(self, event):
if event.is_directory:
return
path = Path(getattr(event, "dest_path", "") or event.src_path)
try:
size_now = path.stat().st_size
head = path.read_bytes()[:5] if size_now else b""
tail = path.read_bytes()[-8:] if size_now else b""
except (FileNotFoundError, PermissionError) as exc:
size_now, head, tail = -1, b"", repr(exc).encode()[:40]
threading.Timer(10, lambda: print(f" {path.name} final size {path.stat().st_size}")).start()
print(f"{event.event_type:<8} {path.name:<24} size={size_now:<9} head={head!r} tail={tail!r}")
if __name__ == "__main__":
obs = Observer()
obs.schedule(Probe(), str(WATCH))
obs.start()
try:
while True:
time.sleep(1)
except KeyboardInterrupt:
obs.stop()
obs.join()
created invoice-2291.pdf size=0 head=b'' tail=b''
modified invoice-2291.pdf size=65536 head=b'%PDF-' tail=b'\x9e\x8a\x11...'
modified invoice-2291.pdf size=3180544 head=b'%PDF-' tail=b'0 obj\n<<'
closed invoice-2291.pdf size=4213772 head=b'%PDF-' tail=b'%%EOF\n'
invoice-2291.pdf final size 4213772
At created the file is empty; at the intermediate modified events it starts with %PDF- but ends mid-object. Only the last event shows the %%EOF trailer and the final size.
Fix: Enqueue on Any Event, Process Only Complete Files
Do not process inside the event handler. Queue the path on created, modified, closed and moved, and let a worker confirm the file is complete: size and modification time unchanged for a quiet period, readable without a sharing error, and — where the format allows — structurally complete. Changed lines carry comments.
# pip install "watchdog>=4.0" pypdf
import queue
import threading
import time
import zipfile
from pathlib import Path
from watchdog.events import FileSystemEventHandler
from watchdog.observers import Observer
WATCH = Path("drop/incoming")
class Enqueue(FileSystemEventHandler):
def __init__(self, work: "queue.Queue[Path]"):
self.work = work
def on_any_event(self, event): # changed: never process here
if event.is_directory or event.event_type == "deleted":
return
self.work.put(Path(getattr(event, "dest_path", "") or event.src_path)) # changed: moved -> dest
def structurally_complete(path: Path) -> bool:
suffix = path.suffix.lower()
try:
if suffix == ".pdf":
with path.open("rb") as fh:
fh.seek(max(0, path.stat().st_size - 1024))
return b"%%EOF" in fh.read() # changed: trailer present
if suffix in {".xlsx", ".docx", ".pptx", ".zip"}:
with zipfile.ZipFile(path) as zf: # changed: central directory readable
return zf.testzip() is None
return True
except (OSError, zipfile.BadZipFile):
return False
def is_complete(path: Path, quiet: float = 2.0, timeout: float = 900.0) -> bool:
deadline, last, since = time.monotonic() + timeout, None, None
while time.monotonic() < deadline:
try:
st = path.stat()
except FileNotFoundError:
return False
current = (st.st_size, st.st_mtime_ns)
if current != last or st.st_size == 0:
last, since = current, None # changed: still growing
elif since is None:
since = time.monotonic()
elif time.monotonic() - since >= quiet: # changed: stable for quiet period
try:
with path.open("rb"):
pass # changed: sharing check (Windows)
except PermissionError:
since = None
continue
if structurally_complete(path):
return True
since = None # stable but truncated: keep waiting
time.sleep(0.5)
return False
def worker(work: "queue.Queue[Path]", process, stop: threading.Event) -> None:
pending: set[Path] = set()
while not stop.is_set():
try:
path = work.get(timeout=1)
except queue.Empty:
continue
if path in pending:
continue # changed: ignore event bursts
pending.add(path)
try:
if is_complete(path):
process(path)
finally:
pending.discard(path)
The structural check matters for writers that pause mid-transfer — a network hiccup can leave a file unchanged for longer than the quiet period while incomplete. A PDF without %%EOF near its end, or a ZIP-based Office file without a readable central directory, is treated as still in progress. CSV files have no trailer to check, so their safety comes from the quiet period alone; give slow CSV producers a longer one.
Variant Fix 1: Producers That Rename Into Place
The most reliable completeness signal is the one producers give you: write to a temporary name, then rename. A rename within one file system is atomic, so the final name only ever refers to a complete file. When you control the producer, make it do this; when you run the watcher, react to moved events for the destination name and ignore temporary names:
# stdlib only
import os
from pathlib import Path
def write_atomically(dest: Path, data: bytes) -> None:
"""Producer side: readers never see a partial dest."""
tmp = dest.with_name(f".{dest.name}.part")
with tmp.open("wb") as fh:
fh.write(data)
fh.flush()
os.fsync(fh.fileno()) # data on disk before the rename
os.replace(tmp, dest) # atomic on the same file system
IGNORE = (".part", ".tmp", ".crdownload", ".partial", ".filepart")
def watcher_should_consider(path: Path) -> bool:
return not path.name.startswith(".") and not path.name.endswith(IGNORE)
os.replace is atomic only within one file system. Writing the temporary file in /tmp and renaming into a watched folder on another mount becomes a copy followed by a delete — exactly the non-atomic write you were trying to avoid. Keep the temporary file in the watched directory (hidden by its leading dot) or a sibling directory on the same mount. SFTP clients such as WinSCP use .filepart suffixes by default; adding them to the ignore list turns their uploads into clean rename deliveries.
Variant Fix 2: Linux closed Events
On Linux, watchdog 3 and later report inotify's IN_CLOSE_WRITE as a closed event. It lets the stability check succeed as soon as the writer closes the file instead of after the full quiet period:
# pip install "watchdog>=4.0"
import queue
from pathlib import Path
from watchdog.events import FileSystemEventHandler
class FastPathOnClose(FileSystemEventHandler):
def __init__(self, ready: "queue.Queue[Path]", maybe: "queue.Queue[Path]"):
self.ready, self.maybe = ready, maybe
def on_closed(self, event): # writer closed after writing (Linux only)
if not event.is_directory:
self.ready.put(Path(event.src_path))
def on_created(self, event):
if not event.is_directory:
self.maybe.put(Path(event.src_path))
def on_moved(self, event):
if not event.is_directory:
self.ready.put(Path(event.dest_path))
Treat closed as a strong hint, not proof: a writer that opens and closes the file several times — some scanners and sync clients do — emits several closed events, the first long before the file is complete. Still run the structural check before processing. On macOS and Windows no closed event arrives, so the quiet-period path from the main fix remains the only mechanism.
Choosing the Quiet Period
A fixed two-second quiet period is a reasonable start but not universal. Measure how long producers actually pause between writes, and set the period per source folder:
# pip install "watchdog>=4.0"
import statistics
import time
from pathlib import Path
def measure_write_gaps(path: Path, poll: float = 0.1, idle_stop: float = 30.0) -> dict:
"""Watch one file being written and report the longest pause between size changes."""
sizes, gaps, last_change = [], [], time.monotonic()
last_size = -1
while time.monotonic() - last_change < idle_stop:
try:
size = path.stat().st_size
except FileNotFoundError:
time.sleep(poll)
continue
if size != last_size:
now = time.monotonic()
if last_size >= 0:
gaps.append(now - last_change)
last_change, last_size = now, size
sizes.append(size)
time.sleep(poll)
return {"final_bytes": last_size, "longest_gap_s": max(gaps, default=0),
"median_gap_s": statistics.median(gaps) if gaps else 0}
Run it while a real delivery happens from each producer — a busy scanner, an upload over a slow VPN — and set the quiet period to three or four times the longest gap observed. Record those numbers alongside the watcher configuration so the next person who sees a truncated file can compare against a measured baseline instead of guessing.
Verification
Reproduce the failure with a deliberately slow writer and assert the processing function sees the final file exactly once.
# pip install "watchdog>=4.0"
import queue
import threading
import time
from pathlib import Path
from watchdog.observers import Observer
def slow_pdf(path: Path, chunks: int = 12, pause: float = 0.4) -> int:
body = b"%PDF-1.7\n" + b"0" * 300_000 * chunks + b"\n%%EOF\n"
step = len(body) // chunks
with path.open("wb") as fh:
for i in range(0, len(body), step):
fh.write(body[i:i + step])
fh.flush()
time.sleep(pause)
return len(body)
def test_complete_only(folder: Path) -> None:
folder.mkdir(parents=True, exist_ok=True)
seen: list[int] = []
work, stop = queue.Queue(), threading.Event()
obs = Observer()
obs.schedule(Enqueue(work), str(folder))
obs.start()
t = threading.Thread(target=worker, args=(work, lambda p: seen.append(p.stat().st_size), stop))
t.start()
expected = slow_pdf(folder / "slow.pdf")
time.sleep(6)
stop.set(); obs.stop(); obs.join(); t.join()
assert seen == [expected], f"processed sizes {seen}, expected exactly [{expected}]"
print("processed once, at full size")
if __name__ == "__main__":
test_complete_only(Path("drop/test"))
A pause of 0.4 seconds between chunks is shorter than the two-second quiet period, so the test also confirms the worker does not fire during normal write gaps. Increase the pause beyond the quiet period to confirm the structural check catches a stalled writer.
FAQ
Can I just time.sleep(5) in on_created?
It blocks the observer thread, delays every other event, and still fails for files that take longer than five seconds to copy. Queue and check instead.
Does on_modified fire once per write?
No fixed rule — the OS coalesces writes differently by platform and buffer size. Treat modifications as "maybe changed", never as a count.
How do I handle files that never finish (aborted uploads)?
The timeout in is_complete returns False. Move such files to a stale folder after a longer grace period so they do not block the queue or get processed later by accident.
Is checking file locks with fcntl better on Linux?
Advisory locks only work if the writer takes them, which most tools do not. The quiet-period and format checks work regardless of the writer's behaviour.
Related
- Watching Folders for Incoming Documents — the complete watcher design
- Move Processed Files to Archive Folders — what to do with a file once processed
- Fix PermissionError Writing Excel File — the same Windows sharing violation from the writer's side
- Fix pypdf PdfFileReader Deprecation Error — reading PDFs with the current pypdf API once they are complete