Skip to content
Go back

inotifywait: Scripts That React to Files

By KingPin 15 min read
inotifywait: Scripts That React to Files
Contents

Stop Polling a Folder Like It Owes You Money

Somewhere on your server there is a cron job that runs every minute, lists a directory, and finds nothing 1,439 times a day. It works. It also wakes up a shell, forks ls, and compares timestamps to answer a question the kernel already knows the answer to.

The kernel has a feature called inotify. It tells you the instant a file is created, written, moved, or deleted. The inotifywait command from the inotify-tools package puts that feature in a shell pipe. For the simpler job of “rerun this command when I save a file”, a tiny tool called entr does it in one line. Use inotifywait when your script needs to know which file changed and what happened to it. Use entr when you just want the same command to run again.

Full example: Clone the working files at github.com/KingPin/sumguy-examples/linux/inotifywait-shell-scripts

I ran every command below in a throwaway Debian trixie container with inotify-tools 4.23.9.0 and entr 5.7 (the Debian packages). As of November 2026 upstream is at inotify-tools 4.26.272 and entr 5.9, so your distro may be a bit behind. Everything here uses flags from the older versions, so you are fine either way.

(Systemd path units can also trigger a service on file changes. They get their own post.)

Install and Try It in 30 Seconds

Terminal window
sudo apt install inotify-tools entr # Debian/Ubuntu
sudo dnf install inotify-tools entr # Fedora (entr may need EPEL on RHEL clones)

Open two terminals. In the first, watch a directory:

Terminal window
mkdir /tmp/demo
inotifywait /tmp/demo

In the second, create a file:

Terminal window
touch /tmp/demo/hello.txt

The first terminal prints one event line and exits. That is the default behavior: wait for one event, report it, quit. Add -m (monitor) to keep listening forever, and -q to silence the “Setting up watches” chatter:

Terminal window
inotifywait -m -q /tmp/demo

Now every touch, cat, and rm in that directory prints a line. The raw output is noisy. You will want to pick your events and format the output.

Events: Pick the Right One or Eat Corrupt Files

The -e flag selects events. The list lives in inotifywait --help: access, modify, attrib, close_write, close_nowrite, open, moved_to, moved_from, create, delete, delete_self, move_self, unmount, and a few combined names like close and move.

Beginners reach for create first. It sounds right. It is a trap. Here is a slow upload simulated with a writer that takes a second between two lines:

Terminal window
mkdir d1
inotifywait -m -q --format '%e %f' -e create -e modify -e close_write d1 &
( echo part1; sleep 1; echo part2 ) > d1/big.txt
CREATE big.txt
MODIFY big.txt
MODIFY big.txt
CLOSE_WRITE,CLOSE big.txt

create fires the moment the file exists, with zero bytes in it. modify fires on every write. If your script reacts to either one, it will happily gzip, upload, or parse half a file, and your 2 AM self will be reading a corrupt-archive error.

close_write fires once, when the writer closes the file. That is your “this file is done” signal. For files that arrive by mv or rsync (which writes to a temp name, then renames), add moved_to. The rule for a watch folder is simple:

-e close_write -e moved_to

Both forms of -e work: repeat the flag or comma-separate (-e close_write,moved_to).

The Watch-Folder Script

Here is the pattern you will reuse. It processes anything that lands in an inbox, moves it out of the way, and survives ugly filenames.

watch-inbox.sh
#!/usr/bin/env bash
set -u
INBOX=${INBOX:-/srv/inbox}
DONE=${DONE:-/srv/done}
mkdir -p "$INBOX" "$DONE"
process() {
local path=$1
[ -f "$path" ] || return 0 # already handled, or a directory
printf 'processing: %s (sha256 %s)\n' "$path" "$(sha256sum < "$path" | cut -c1-12)"
mv -n -- "$path" "$DONE"/
}
# Start the monitor first so nothing is missed during the startup sweep.
exec 3< <(inotifywait -m -q -e close_write -e moved_to \
--no-newline --format '%w%f%0' "$INBOX")
# Catch files that were already waiting.
find "$INBOX" -maxdepth 1 -type f -print0 | while IFS= read -r -d '' f; do process "$f"; done
while IFS= read -r -d '' path <&3; do
process "$path"
done

I tested it with a file that was already waiting, a slow two-second upload named slow upload.txt, and a file moved in with mv. All three were processed once, and all three ended up in the done directory.

Four decisions in that script do the real work. First, -m instead of a loop. The lazy version is while inotifywait -e close_write dir; do handle; done. Between the moment inotifywait exits and the moment the next one starts, no watch exists. Files that land in that gap are never reported. A single -m process keeps the watch alive and queues events for you. Events queue up while your handler runs, so a slow handler delays work. It does not drop it, until the kernel queue (fs.inotify.max_queued_events) overflows.

Second, NUL-separated output. The default output splits fields on spaces, so my file.txt becomes two words and a filename with a newline breaks a while read loop outright. --format '%w%f%0' prints the directory plus filename followed by a NUL byte, and --no-newline stops inotifywait from tacking on a newline. read -r -d '' reads NUL-terminated records. I fed it a file named my file.txt and one with a newline in the middle of the name, and both came through whole. If you find NUL records too fussy, -c gives CSV output, which the man page calls useful for names with spaces.

Third, start the monitor, then sweep. If you sweep first and start watching second, anything that arrives between the two steps is missed. Starting the monitor first means a file can show up twice (once in the sweep, once as an event), which is why process checks that the file still exists and moves it away.

Fourth, move finished files out of the inbox. That makes the script idempotent. Restart it, crash it, run it twice: the inbox only contains work that has not been done.

Never write the output into the directory you are watching. A script that reacts to its own output is an infinite loop with extra steps.

Format, Filters, and Time

The --format flag takes printf-style conversions. The ones you need are %w (the watched path), %f (the filename inside it), %e (the events, comma-separated), and %T (a timestamp, shaped by --timefmt):

Terminal window
inotifywait -m -q --timefmt '%H:%M:%S' --format '%T %w %f %e' -e close_write d5
20:02:48 d5/ a.txt CLOSE_WRITE,CLOSE

Editors and tools scatter temp files everywhere (.swp, .tmp, ~). Filter them with --exclude, which takes a POSIX extended regular expression matched against the path:

Terminal window
inotifywait -m -q -e close_write --exclude '\.(tmp|swp)$' --format '%f' d6

I created keep.txt and skip.tmp in that directory and only keep.txt printed. --include is the inverse (only paths matching are reported). Both have case-insensitive twins, --excludei and --includei. The help text says only the last --exclude counts, so combine patterns into one with |.

For a one-shot wait inside a script, -t sets a timeout in seconds. The exit codes, straight from the help text: 0 for a wanted event, 1 for an unwanted event or an error, 2 for a timeout. I confirmed the timeout case:

Terminal window
inotifywait -q -t 1 -e close_write d3; echo "exit: $?"
exit: 2

That makes if inotifywait -q -t 30 -e close_write "$file"; then ... a clean “wait up to 30 seconds for this to finish” check.

Recursion, and the Watch Limit

-r watches a whole tree, including directories created later. Under the hood, inotify needs one watch per directory, and the kernel caps the total per user:

Terminal window
cat /proc/sys/fs/inotify/max_user_watches

The default depends on your kernel. Since Linux 5.11 it scales with RAM (sized so watches use at most about 1% of memory, clamped between 8192 and 1048576), so older docs quoting a flat 8192 are out of date on a current box. The machine I tested on printed 524288. A small VM or an older kernel can be much lower. Point -r at a photo library with 100,000 folders on a box with a low limit, and inotifywait stops with an error saying the upper limit on inotify watches was reached.

Raise the limit with a sysctl:

Terminal window
echo 'fs.inotify.max_user_watches = 524288' | sudo tee /etc/sysctl.d/90-inotify.conf
sudo sysctl --system

Each watch costs a little kernel memory, so do not set it to a billion “just in case”. Better: watch the directory you care about, not its parent. Pointing -r at / is the forklift-and-couch move.

The Editor Gotcha: Watch the Directory

You watch config.ini with inotifywait -m config.ini. You edit it in vim. You edit it again. Nothing fires the second time.

Many editors, and sed -i, save by writing a new file and renaming it over the old one. Your watch is attached to the old inode, which now belongs to nobody. I reproduced it with sed -i on a watched file:

OPEN
MODIFY
CLOSE_WRITE,CLOSE
OPEN
ACCESS
CLOSE_NOWRITE,CLOSE
ATTRIB
DELETE_SELF

The first three lines are a normal in-place write. The rest is sed -i: it reads the file, then the old file is deleted (DELETE_SELF), and the monitor exits because it has nothing left to watch. A write I made afterward went unseen.

Watch the directory instead and filter by name. The same sed -i, with the directory watched:

CLOSE_WRITE,CLOSE sedEX9A10
MOVED_TO f.txt

The temp file closes, then gets renamed onto f.txt, which shows up as moved_to. This is why the watch-folder script uses close_write plus moved_to. For a single config file, the pattern is:

reload-on-config.sh
#!/usr/bin/env bash
dir=/etc/myapp; name=config.ini
inotifywait -m -q -e close_write -e moved_to --format '%f' "$dir" |
while IFS= read -r f; do
[ "$f" = "$name" ] && systemctl reload myapp
done

Debounce: One Save Is Never One Event

Save a project in an IDE or run git checkout and you get a burst of events. Reacting to each one runs your rebuild ten times. A debounce waits for quiet, then fires once:

debounce.sh
#!/usr/bin/env bash
# Usage: ./debounce.sh /srv/site/src 2 ./rebuild.sh
dir=$1 quiet=$2; shift 2
inotifywait -m -r -q -e close_write,moved_to,delete --format '%w%f' "$dir" |
while IFS= read -r _first; do
# keep draining events until $quiet seconds pass with nothing new
while IFS= read -r -t "$quiet" _more; do :; done
"$@"
done

Ten files written 100 ms apart produced exactly one rebuild at ... line in my test with a one-second quiet window. The trick is read -t: the inner loop keeps eating events and returns the moment a full quiet window passes with none.

Pick the quiet window to match your slowest writer. Two seconds suits an IDE. A big rsync may need ten, or better, wait for its moved_to events.

entr: When You Just Want to Rerun a Command

Half the scripts people write with inotifywait are “run the tests when I save”. For that, skip the plumbing. entr reads a list of files on standard input and reruns a command when any of them changes:

Terminal window
ls *.py | entr pytest
find src -name '*.go' | entr -c go test ./...

The flags worth knowing, all from man entr in the tested package:

FlagWhat it does
-rRestart a long-running process (a server) with SIGTERM on each change
-cClear the screen before each run (twice also clears scrollback)
-dWatch the directories of the listed files and exit when a new file appears
-nNon-interactive: do not read from the TTY (needed in cron, CI, containers)
-pPostpone the first run until a file changes
-sRun the first argument through $SHELL, so pipes and && work
-zExit after the command finishes
/_Placeholder for the first file that triggered the run

Show which file triggered the run:

Terminal window
ls *.sh *.txt | entr -n sh -c 'echo "ran, trigger: $0"' /_
ran, trigger: /tmp/tmp.JnAQ6GyDgF/app.sh

Note that entr runs a command and /_ is substituted into its arguments. Without -s or sh -c there is no shell, so no pipes.

-r is the one that earns its keep for dev servers. I ran a sleep 100 stand-in under entr -r and edited the watched file; the old process died and a new one started with a fresh PID:

server start pid 64
server start pid 67

The new-file trap

entr takes its file list once, at startup. A file created later is not in the list. With -d it notices the new file, but its answer is to exit with status 2 and the message entr: directory altered. I confirmed both. The fix is a loop that re-runs ls and starts entr again, straight from the man page:

Terminal window
while sleep 0.1; do ls src/*.rb | entr -d make; done

That loop fakes what inotifywait -m -r does natively, so if you find yourself writing it a lot, reconsider.

Which One Do You Reach For

You want to…Use
Rerun tests or a build on saveentr
Restart a dev server on changeentr -r
Process each new file in a folderinotifywait -m
Know which file changed and howinotifywait --format
Watch a whole tree that growsinotifywait -r
Run something once per burstinotifywait plus a debounce

My rule: if the command does not care which file changed, entr. If your script branches on the filename or the event type, inotifywait. Either one beats a cron job polling ls for the same answer.

Where inotify Does Not Reach

inotify reports changes made through the local kernel’s filesystem calls. Two places to be careful:

I did not test any of the network or desktop cases above. The container tests ran on a local filesystem only.

The SumGuy Take

Use entr for the 80% case, because one line beats twenty. Use inotifywait -m with close_write,moved_to for anything that handles files, and always watch the directory, never the file. Pipe it through NUL-separated output so a weird filename does not become your 2 AM page. Add a debounce when saves come in bursts.

And delete the cron job that polls every minute. It had a good run.

Common Questions

Does inotifywait work on NFS mounts?

No, not for changes made by other machines. The kernel generates inotify events only for filesystem calls made on the local machine. Writes through your own NFS mount do trigger events, but a change from another client does not. Run the watcher on the NFS server, or poll instead.

How do I stop inotifywait from missing events between runs?

Run inotifywait -m once instead of calling inotifywait in a loop. Without -m, the command exits after one event, and files that arrive before the next call starts are never reported. With -m, one process keeps the watch open and the events queue in the pipe.

Should I use close_write or modify to detect a finished file?

Use close_write. The modify event fires on every write, so a large upload triggers it many times while the file is still incomplete. The close_write event fires once after the writer closes the file. Add moved_to to catch files that arrive by mv or rsync renames.

What is the difference between entr and inotifywait?

The entr tool reruns one command whenever any listed file changes, and it does not tell the command what happened. The inotifywait tool prints each event with the path and event type, so a script can branch on them. Choose entr for rebuilds, inotifywait for file-handling scripts.

Does entr work on macOS?

Yes. The entr project supports BSD and macOS as well as Linux, so the same one-liners run on a Mac. The inotifywait command does not, because inotify is a Linux kernel feature. On macOS, use entr or a native watcher built on FSEvents.


Share this post on:

Send a Webmention

Written about this post on your own site? Send a webmention and it'll show up above once verified.


Previous Post
systemd Path Units: React to New Files
Next Post
Langfuse vs Phoenix: Self-Host LLM Tracing

Discussion

Powered by Garrul . Sign in with GitHub or Google, or post anonymously.

Related Posts