Skip to content
Go back

systemd Path Units: React to New Files

By KingPin 13 min read
systemd Path Units: React to New Files
Contents

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:

inbox.path
[Unit]
Description=Watch the inbox directory
[Path]
DirectoryNotEmpty=%h/inbox
MakeDirectory=yes
DirectoryMode=0700
[Install]
WantedBy=default.target

And the service it starts:

inbox.service
[Unit]
Description=Process everything in the inbox directory
[Service]
Type=oneshot
SyslogIdentifier=process-inbox
ExecStart=%h/.local/bin/process-inbox.sh %h/inbox %h/processed

Unit= 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:

Terminal window
mkdir -p ~/.config/systemd/user ~/.local/bin
cp inbox.path inbox.service ~/.config/systemd/user/
cp process-inbox.sh ~/.local/bin/
systemctl --user daemon-reload
systemctl --user enable --now inbox.path

You 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.

process-inbox.sh
#!/usr/bin/env bash
# Move every regular file out of the inbox, one at a time.
# Usage: process-inbox.sh INBOX_DIR DONE_DIR
set -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/"
done

The 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.path
TRIGGER_PATH=/tmp/sgpath-9Vxn/inbox

So 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:

Terminal window
cd ~/inbox && for i in 1 2 3; do echo "data $i" > file$i.txt; done
journalctl --user -t process-inbox --no-pager -o cat

Note 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?).

DirectiveWhat it watchesKind
PathExists=the mere existence of a file or directorylevel
PathExistsGlob=at least one file matching the globlevel
DirectoryNotEmpty=the directory contains at least one filelevel
PathChanged=activated when a file open for writing gets closededge
PathModified=like PathChanged=, plus simple writesedge

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:

Terminal window
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/hello
journalctl --user -t demo --no-pager -o cat

That 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.

TriggerFiredResult
PathChanged= on the directoryonce when the file was created, again when it was closedservice ran while the file was half-written
PathModified= on the directoryonce per writethree writes, three runs
DirectoryNotEmpty=once, when the file appearedservice grabbed and removed the half-written file
any of them, via mv into the directoryonceclean

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:

Terminal window
echo "partial upload" > ~/inbox/.upload.tmp
mv ~/inbox/.upload.tmp ~/inbox/upload.txt

The 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):

Terminal window
d=$(mktemp -d)
systemd-run --user --unit=demo --path-property=DirectoryNotEmpty=$d \
--service-type=oneshot true
touch $d/stuck
systemctl --user status demo.path --no-pager

Within 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 ago

The 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:

Recovery is two commands once you fix the cause:

Terminal window
systemctl --user reset-failed inbox.path inbox.service
systemctl --user start inbox.path

For 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:

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:

Terminal window
systemctl --user list-paths --all
PATH 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.service

For output from your script, query the identifier, not the path unit:

Terminal window
journalctl --user -t process-inbox --no-pager

Path-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/.


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.


Next Post
inotifywait: Scripts That React to Files

Discussion

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

Related Posts