Inotify delivers Linux filesystem notifications through an instance with registered watches and a readable event queue. Each record identifies a watch, event mask, and possibly a name and move cookie. This can avoid repeatedly checking unchanged paths, but the stream is not an unlimited audit log. Correct consumers interpret notifications as evidence that relevant state may need to be inspected again.
When it breaks
Watching a directory does not recursively watch its descendants. It reports relevant activity on immediate entries, so a new subdirectory needs its own watch if deeper changes matter. A tree watcher must handle initial enumeration and directories arriving later. Assuming one root watch covers the entire tree silently misses changes below that root.
A file watch follows the watched object, not a pathname forever. An editor can finish writing a temporary file and rename it over the target. The parent directory reports the replacement, while the old file watch does not transfer to the new inode. Waiting for a later close-write event on the destination name can fail because the replacement was already closed before publication.
Identical unread events can coalesce, and a full queue drops events while reporting IN_Q_OVERFLOW. Event counts therefore do not reliably count writes. After overflow, a consumer cannot reconstruct every missing operation from the remaining records. It must reconcile the current watched state instead of continuing with an assumption that its event-derived model is complete.
Check it quickly
Save the script as inotify-events.py and run python3 inotify-events.py in a disposable Linux environment. The recorded run used Linux 6.12.76, Python 3.11.2, and ext4. It creates ASCII-named temporary entries and intentionally fills its own queue. The configured queue limit must be at most 32768; the script reads that limit without changing any sysctl:
from pathlib import Path
from tempfile import TemporaryDirectory
import ctypes,os,struct
MODIFY=0x2;ATTRIB=0x4;CLOSE_WRITE=0x8;MOVED_FROM=0x40;MOVED_TO=0x80
CREATE=0x100;DELETE=0x200;DELETE_SELF=0x400;MOVE_SELF=0x800;OVERFLOW=0x4000;IGNORED=0x8000
libc=ctypes.CDLL(None,use_errno=True)
libc.inotify_init1.argtypes=[ctypes.c_int];libc.inotify_init1.restype=ctypes.c_int
libc.inotify_add_watch.argtypes=[ctypes.c_int,ctypes.c_char_p,ctypes.c_uint32];libc.inotify_add_watch.restype=ctypes.c_int
header=struct.Struct('iIII')
with TemporaryDirectory(prefix='xbcatm-inotify-') as directory:
root=Path(directory);fd=libc.inotify_init1(os.O_NONBLOCK|os.O_CLOEXEC);assert fd>=0,os.strerror(ctypes.get_errno())
def watch(path,mask):
wd=libc.inotify_add_watch(fd,os.fsencode(path),mask);assert wd>=0,os.strerror(ctypes.get_errno());return wd
def drain():
events=[]
while True:
try:data=os.read(fd,65536)
except BlockingIOError:return events
offset=0
while offset<len(data):
wd,mask,cookie,length=header.unpack_from(data,offset);offset+=header.size
name=data[offset:offset+length].split(b'\0',1)[0].decode();offset+=length
events.append((wd,mask,cookie,name))
try:
parent=watch(root,MODIFY|CLOSE_WRITE|CREATE|DELETE|MOVED_FROM|MOVED_TO)
sub=root/'sub';sub.mkdir();drain();(sub/'unwatched').touch();assert drain()==[]
nested=watch(sub,CREATE);(sub/'watched').touch();assert any(wd==nested and mask&CREATE and name=='watched' for wd,mask,_,name in drain())
print('a directory watch is not recursive; adding a child-directory watch observes its new entries')
counter=root/'counter';stream=os.open(counter,os.O_CREAT|os.O_WRONLY,0o600);drain()
try:
for _ in range(3):os.write(stream,b'x')
finally:os.close(stream)
events=drain();assert sum(wd==parent and bool(mask&MODIFY) and name=='counter' for wd,mask,_,name in events)==1
moved=root/'moved';counter.rename(moved);events=drain()
before=next(e for e in events if e[1]&MOVED_FROM and e[3]=='counter');after=next(e for e in events if e[1]&MOVED_TO and e[3]=='moved')
assert before[2]==after[2]!=0
print('three unread identical modifications coalesce; rename FROM/TO events share a nonzero cookie')
target=root/'target';target.write_bytes(b'old');file_watch=watch(target,MODIFY|ATTRIB|DELETE_SELF|MOVE_SELF);drain()
replacement=root/'replacement';replacement.write_bytes(b'new');drain();replacement.replace(target);events=drain()
assert any(wd==file_watch and mask&IGNORED for wd,mask,_,_ in events)
assert any(wd==parent and mask&MOVED_TO and name=='target' for wd,mask,_,name in events)
assert not any(wd==parent and mask&CLOSE_WRITE and name=='target' for wd,mask,_,name in events)
target.write_bytes(b'newer');events=drain()
assert any(wd==parent and mask&MODIFY and name=='target' for wd,mask,_,name in events)
assert all(wd!=file_watch for wd,_,_,_ in events)
print('replacement invalidates the old file watch; the parent-directory watch continues to observe the pathname')
burst=root/'burst';burst.mkdir();drain();watch(burst,CREATE)
limit=int(Path('/proc/sys/fs/inotify/max_queued_events').read_text());assert 0<limit<=32768,'use a disposable environment with a bounded queue limit'
for number in range(limit+8):(burst/str(number)).touch()
events=drain();assert any(wd==-1 and mask&OVERFLOW for wd,mask,_,_ in events)
current=len(list(burst.iterdir()));assert current==limit+8
print('actual queue overflow:',len(events),'events retained for',current,'creates; rescan sees the current entries')
finally:os.close(fd)
The first case watches the parent directory, creates a subdirectory, and drains the parent event. Creating a file inside that subdirectory produces no event for the parent watch. Adding an explicit subdirectory watch makes the next creation visible. This distinguishes direct-entry coverage from recursive monitoring using the same inotify instance.
Three writes to one file occur before the queue is read. The fixture observes one matching modify event, demonstrating coalescing rather than one notification per write. Renaming the file then produces FROM and TO events with the same nonzero cookie. The cookie associates these two records; it is not a durable identifier for the file’s entire future lifetime.
Next, the script watches a file and replaces its pathname with a previously written and closed file. The old watch receives an ignored event when its object is deleted, while the parent watch reports MOVED_TO for the target name. No destination close-write event accompanies that publication. A subsequent write to the replacement is visible through the parent watch, not the removed file watch.
For overflow, the fixture creates 16392 distinct entries with a configured limit of 16384 queued events. It receives 16385 records, including the overflow notification with watch descriptor minus one. A directory rescan finds all 16392 current entries. Those results show both missing event history and a concrete way to recover present-state knowledge.
All watches share one owned instance, which closes before temporary-tree cleanup. The experiment does not induce a system-wide watch shortage or modify other applications’ queues. Its deliberately bounded burst validates overflow behavior without requiring an application to remain permanently overloaded or relying on an assumed loss signal that was never observed.
Reconcile names and object lifetimes
Pair move cookies when both sides are visible, but allow for moves into or out of the observed set and records separated by other events. Recheck the relevant paths before acting. A name in an event describes the reported change; it does not guarantee that the same object will still occupy that name when the consumer later opens it.
For trees, establish watches and reconcile directory contents while accounting for activity during enumeration. On overflow, rebuild the affected state from the filesystem and restore the necessary watch coverage. An overflow record does not identify one missing filename, so handling it as an ordinary per-watch event is insufficient.
Use notifications to trigger work that tolerates repeated or combined changes, such as reloading a complete configuration after validating its current contents. If every transition must be preserved, use an application journal or another interface with the required history contract. Inotify is useful precisely when its coverage and loss rules match the consumer’s actual needs.
Sources
- inotify(7), event formats, coalescing, overflow, renames, and nonrecursive watches, checked 2026-09-12.
- inotify_add_watch(2), watch registration and object identity, checked 2026-09-12.
- Linux 6.12 inotify UAPI, event structure and flag values used by the executable fixture, checked 2026-09-12.