You Wrote a Watcher Loop to Process a Folder. Systemd Already Has One.
You have a folder. Scans land in it, or CSV exports, or camera uploads. Something should happen to each file. The usual answer is a while inotifywait loop in a nohup session that dies the first time the box reboots.
Systemd has a built-in answer: a .path unit watches the folder, and a oneshot .service does the work. No loop, no long-running process, no pidfile. The path unit sits there costing you nothing until a file shows up, then starts the service. If you read the inotifywait post, this is the “they get their own post” part.
I tested everything below on systemd 262, November 2026, as a normal user with user units only. The thing that bites people is not the syntax. It is what happens when the service finishes and the folder is still not empty.
Full example: Clone the working files at github.com/KingPin/sumguy-examples/linux/systemd-path-units
The Smallest Working Drop Folder
Two files. The path unit:
[Unit]Description=Watch the inbox directory
[Path]DirectoryNotEmpty=%h/inboxMakeDirectory=yesDirectoryMode=0700
[Install]WantedBy=default.targetAnd the service it starts:
[Unit]Description=Process everything in the inbox directory
[Service]Type=oneshotSyslogIdentifier=process-inboxExecStart=%h/.local/bin/process-inbox.sh %h/inbox %h/processedUnit= is not set, so inbox.path activates inbox.service. The man page says the default is “a service that has the same name as the path unit, except for the suffix.” You can point it elsewhere with Unit=, but the man page itself recommends keeping the names identical, and so do I.
MakeDirectory=yes creates the watched directory before watching it (default no), and DirectoryMode= sets its mode (default 0755). I used 0700 because an inbox that every local user can write into is a fun surprise for later.
Install and enable it as your own user:
mkdir -p ~/.config/systemd/user ~/.local/bincp inbox.path inbox.service ~/.config/systemd/user/cp process-inbox.sh ~/.local/bin/systemctl --user daemon-reloadsystemctl --user enable --now inbox.pathYou enable and start the .path unit, not the service. The service has no [Install] section on purpose.
The Script: Loop Over Everything, Move Each File Out
This script decides whether your setup works at 2 AM.
#!/usr/bin/env bash# Move every regular file out of the inbox, one at a time.# Usage: process-inbox.sh INBOX_DIR DONE_DIRset -euo pipefail
inbox=${1:?usage: process-inbox.sh INBOX_DIR DONE_DIR}done_dir=${2:?usage: process-inbox.sh INBOX_DIR DONE_DIR}mkdir -p "$done_dir"
echo "triggered by ${TRIGGER_UNIT:-unknown}, path ${TRIGGER_PATH:-unknown}"
# Loop over the whole directory, not just TRIGGER_PATH: several files can# land before the service starts, and systemd reports only one of them.# find skips dotfiles, which is the same rule systemd uses for the watch.find "$inbox" -maxdepth 1 -type f ! -name '.*' -print0 |while IFS= read -r -d '' f; do echo "processing $(basename "$f") ($(wc -c < "$f") bytes)" # Real work goes here. If it fails, the file stays and the service # retriggers until the start limit fails the path unit (loud, not silent # data loss). To keep the watcher alive, mv failures to a failed/ dir instead. mv -- "$f" "$done_dir/"doneThe service does not get a filename. Systemd passes TRIGGER_UNIT and TRIGGER_PATH (documented in systemd.exec, added in version 252), but the man page says the information is “provided in a best-effort way” and that multiple triggers “will be coalesced and only one will be reported.” When I dropped files into a watched directory, TRIGGER_PATH held the watched directory, not the file:
TRIGGER_UNIT=sgtest-a.pathTRIGGER_PATH=/tmp/sgpath-9Vxn/inboxSo treat those variables as debugging output. The reliable pattern is the one above: trigger on “directory not empty,” then process whatever is in the directory and move each file out. Moving the file out is what ends the trigger condition. Keep that in mind, because it comes back in a minute.
Drop three files and check:
cd ~/inbox && for i in 1 2 3; do echo "data $i" > file$i.txt; donejournalctl --user -t process-inbox --no-pager -o catNote the -t. Output from a script that exits in milliseconds can reach the journal without its unit name attached, so journalctl -u inbox.service sometimes showed the Starting and Finished lines and nothing in between. The SyslogIdentifier= line in the service gives every output line a tag that -t finds every time.
In my test run all three were handled and the inbox ended empty. A burst of files normally costs you one service run, not three.
The Five Triggers, and Which Ones Remember
The man page lists five. Two behave like a level (is the thing true right now?) and three behave like an edge (did something just happen?).
| Directive | What it watches | Kind |
|---|---|---|
PathExists= | the mere existence of a file or directory | level |
PathExistsGlob= | at least one file matching the glob | level |
DirectoryNotEmpty= | the directory contains at least one file | level |
PathChanged= | activated when a file open for writing gets closed | edge |
PathModified= | like PathChanged=, plus simple writes | edge |
The man page says that if a path already exists (for PathExists= and PathExistsGlob=) or a directory is already not empty (for DirectoryNotEmpty=) when the path unit starts, the service starts immediately. “Something similar does not apply to PathChanged= and PathModified=.” I confirmed it with transient units. A watch on a directory that already held a file started the service at once for PathExistsGlob=, and did nothing for PathChanged=.
That makes the level triggers ideal for drop folders. After a reboot, files that arrived while the machine was down get processed. An edge trigger would never notice them.
You can try any trigger without writing a unit file. systemd-run takes --path-property= for settings on the path unit:
d=$(mktemp -d)systemd-run --user --unit=demo --path-property=DirectoryNotEmpty=$d \ --service-type=oneshot -p SyslogIdentifier=demo \ bash -c "ls ${d:?}; rm -f ${d:?}/*"touch $d/hellojournalctl --user -t demo --no-pager -o catThat is the same trick as in systemd-run and transient units: throwaway config, nothing left on disk. Stop it with systemctl --user stop demo.path.
Half-Written Files: Which Trigger Fires When
This is where I stopped trusting my own assumptions. I had a writer open a file, write a line, wait 1.5 seconds, write another line, and close it. Then I watched what each trigger did.
| Trigger | Fired | Result |
|---|---|---|
PathChanged= on the directory | once when the file was created, again when it was closed | service ran while the file was half-written |
PathModified= on the directory | once per write | three writes, three runs |
DirectoryNotEmpty= | once, when the file appeared | service grabbed and removed the half-written file |
any of them, via mv into the directory | once | clean |
So PathChanged= does not mean “after the upload finished” when you watch a directory. The man page wording, “activated if the file which was open for writing gets closed”, is accurate for a watched file. For a directory, creating an entry counts as a change too.
The fix has nothing to do with systemd. Deliver files atomically. Write to a temp name, then rename into place:
echo "partial upload" > ~/inbox/.upload.tmpmv ~/inbox/.upload.tmp ~/inbox/upload.txtThe man page notes that “files whose name starts with a dot (i.e. hidden files) are generally ignored when monitoring these paths.” I verified it: a dotfile like .upload.tmp sat in the watched directory and nothing fired. After the mv to a normal name, the service ran once. Dotfile plus rename is the cheapest correct upload protocol there is. If your uploader can’t rename, have it write to a staging directory on the same filesystem and mv from there.
The Retrigger Loop (How a Path Unit Dies)
Here is the section I wrote the whole post for.
The man page says that when the triggered service terminates, “regardless whether it exited successfully or failed,” the monitored paths “are checked immediately again, and the service accordingly restarted instantly.” If the condition is still true, the service starts again. And again.
Let me make that happen. A service that does not empty the directory (here true):
d=$(mktemp -d)systemd-run --user --unit=demo --path-property=DirectoryNotEmpty=$d \ --service-type=oneshot truetouch $d/stucksystemctl --user status demo.path --no-pagerWithin about 80 ms the path unit was dead:
Active: failed (Result: unit-start-limit-hit) since Mon 2026-11-09 09:03:26 EST; 1s agoThe journal showed the loop ending (the unit names come from my test run):
sgtest-l.service: Start request repeated too quickly.sgtest-l.service: Failed with result 'start-limit-hit'.sgtest-l.path: Failed with result 'unit-start-limit-hit'.The service ran five times in about 60 ms. That is the service’s own StartLimitBurst=5 within StartLimitIntervalSec=10s (I read both from systemctl show). The man page explains the propagation: the start-limit error “is propagated from the service unit to the path unit and causes the path unit to fail as well, thus ending the loop.”
The path unit has its own limit, TriggerLimitIntervalSec= (default 2s) and TriggerLimitBurst= (default 200, added in version 250). Both showed up as defaults in systemctl show. In my tests the service limit tripped first, long before 200 triggers. Setting either trigger value to 0 disables that limiting. Do not do that for a service that can fail to empty its directory, because you will get a busy loop instead of a failure.
Common ways to cause this:
- The script crashes before moving the file.
- A subdirectory lands in the inbox. It counts as content, and a script that moves only regular files never removes it. I reproduced this one with a
mkdir.
Recovery is two commands once you fix the cause:
systemctl --user reset-failed inbox.path inbox.servicesystemctl --user start inbox.pathFor a transient unit, reset-failed makes the unit vanish entirely. In my test the follow-up start said the unit was not found. With real unit files you just start it again.
What Happens to Files That Land While the Service Runs
Test: a service that sleeps for 2 seconds, with a second file dropped 1 second in.
With PathChanged=, nothing happened. The service ran once, and the second event was lost. No retrigger after exit.
With DirectoryNotEmpty=, the service ran again as soon as the first run exited, because the directory still held the file that landed mid-run. It kept running until the files were gone.
That is the practical reason to use the level triggers for work queues. An edge trigger can drop an event that fires while the job is busy. A level trigger re-checks the world when the job ends.
One more gotcha: a service with RemainAfterExit=yes never retriggers. The service stays “active (exited)”, so a new start request has nothing to start. I dropped one file, then another a second later. The first was processed, the second sat in the inbox until I stopped the service by hand. RemainAfterExit= is the wrong tool here.
Watches Are Not Recursive
A DirectoryNotEmpty=, PathChanged=, or PathModified= watch covers one directory. I created inbox/sub/ and touched inbox/sub/deep. The PathChanged= watch on inbox stayed silent. Touching a file directly in inbox fired it.
If you need subdirectories, give each its own directive (the man page says multiple directives may be combined) or flatten the layout. Recursive watching is a job for inotifywait with -r.
Path Unit or inotifywait?
My opinion:
- Path unit when the sentence is “run a job when something lands.” Backups, scan folders, import queues, “convert whatever shows up.” It survives reboots, logs to the journal, picks up files that arrived while you were down, and limits its own failure modes.
- inotifywait when you need per-event detail. Which file, which event, rename versus delete, recursive watches. A path unit tells you “something happened here,” and that is all it tells you.
- A timer when the thing you are waiting for is not on a local filesystem. The man page says path units “cannot be used to monitor files or directories changed by other machines on remote NFS file systems.” Poll with a timer instead.
Using a path unit for per-file bookkeeping is like asking a forklift to sort your mail. Using a hand-rolled bash loop to babysit a drop folder is like carrying pallets by hand because the forklift is “complicated.”
User Units, System Units, and Where to Look
Everything above ran as a user unit, with systemctl --user. Files go in ~/.config/systemd/user/. User units only run while you have a session, unless you enable lingering. The loginctl man page says enable-linger keeps a user manager “spawned for the user at boot and kept around after logouts.” For a server job, either enable lingering or install the same pair under /etc/systemd/system/ and drop --user.
To see what is being watched:
systemctl --user list-paths --allPATH CONDITION UNIT ACTIVATES/tmp/sgpath-eGHq/inbox DirectoryNotEmpty sgtest-l.path sgtest-l.service/tmp/sgpath-eGHq/inbox/*.txt PathExistsGlob sgtest-j.path sgtest-j.serviceFor output from your script, query the identifier, not the path unit:
journalctl --user -t process-inbox --no-pagerPath-unit failures like the loop above show up under -u inbox.path.
Run the Kit
The companion repo has the unit files, the script, and test.sh. The test installs sgtest- user units pointed at a temp directory, drops files, checks they got processed, ignores a dotfile, forces the retrigger loop to confirm the path unit fails, recovers with reset-failed, and removes everything on exit. No sudo. If it prints ALL PASSED, your systemd behaves like mine did.
Your 2 AM self will thank you for one thing above all: make the script empty the directory, every time, even when the work for one file fails. Move bad files to a failed/ directory. A file that stays behind is a loop waiting for a reason.
Common Questions
Do systemd path units watch subdirectories?
No. A systemd path unit watches one directory per directive and does not recurse. In testing, a file created inside inbox/sub/ did not trigger a PathChanged= watch on inbox. Add one directive per subdirectory, or use inotifywait -r if you need a recursive watch.
What is the difference between PathChanged and PathModified?
PathModified= fires on simple writes as well as on file close. PathChanged= fires when a file opened for writing is closed, and on a directory it also fired when a file was created. In a three-write test, PathModified= started the service three times. PathChanged= is the quieter choice.
Why did my systemd path unit stop working?
The service probably failed to clear the trigger condition and hit its start limit. With DirectoryNotEmpty= and a script that leaves files behind, the service retriggers until StartLimitBurst=5 trips and the path unit enters failed with unit-start-limit-hit. Fix the script, then run systemctl reset-failed and start the path unit.
Do I need root to use a systemd path unit?
No. A systemd path unit works as a user unit under systemctl --user, with files in ~/.config/systemd/user/. User units run only while you are logged in unless you run loginctl enable-linger. Root is needed only to install system-wide units under /etc/systemd/system/.