OnCallReady

Lesson 4.15 · Filesystem, Permissions, Disk · 25 min read

find: the expression language

In plain words

Imagine sending a very literal helper through a warehouse with a checklist: "look at every box, and if it is a crate, and it is bigger than a bike, and nobody has touched it in a month, put a sticker on it". The helper walks every aisle, checks each box against the list from left to right, and does exactly what the list says, including stickering everything if you put "sticker it" before the conditions.

find is that helper. The start point is where to walk (/var/log), the tests are the checklist (-type f -name '*.gz' -mtime +30), and the action is what to do (-print, -delete, -exec). Tests are joined with AND, -o means OR and binds loosely, so use parentheses. Quote the patterns, or the shell reads them first.

Why find, and not ls and your eyes

"Which files are older than 30 days?", "is anything world-writable?", "delete the old session files but keep this week's" - questions about thousands of files at once. find answers them precisely, and it is also the tool that deletes them, so it pays to know exactly how it reads what you type.

What you need to know already: 4.5 (modes), 4.9 (find -perm -4000), 4.3 (userdel leaves files with a bare UID), 1.7 (pipes).

The shape of every find

find [start points...] [expression]
find /var/log -type f -name '*.gz' -mtime +30 -print
     └──┬───┘ └──────────────┬──────────────┘ └─┬──┘
      where         tests, ANDed together      action

find walks every entry under each start point (the directories you name) and evaluates the expression against it, left to right. Tests are true or false; actions (-print, -delete, -exec) do something and are also true or false. With no action at all, find adds -print for you.

Tests you will use every week

-type f / d / l          regular file / directory / symlink
-name '*.log'            the NAME matches a glob (case-sensitive; -iname ignores case)
-path '*/cache/*'        the whole PATH matches a glob
-size +100M              bigger than 100 MiB     (c bytes, k KiB, M MiB, G GiB)
-mtime +7                modified more than 7 days ago
-mtime -1                modified less than 1 day ago
-mmin -30                in the last 30 minutes
-newer ref               modified more recently than the file ref
-user appuser / -group ops
-nouser / -nogroup       owned by a UID/GID with no name - leftovers of a userdel
-perm ...                mode tests, below
-empty                   empty file or empty directory
-links +1                more than one hard link

A glob is a shell-style pattern: * any characters, ? one character. -maxdepth 1 means "do not go deeper than the start directory itself"; -printf '%s %p\n' prints size in bytes and path (more under Actions); sort -rn sorts numerically, biggest first.

$ find /etc -maxdepth 1 -name 'host*'
/etc/hostname
/etc/hosts
$ sudo find /var/log -type f -printf '%s %p\n' | sort -rn | head -3
45837 /var/log/nginx/access.log
3292 /var/log/nginx/error.log
190 /var/log/apt/history.log

Quote the glob. Always.

The shell expands an unquoted *.conf before find runs. In a directory with no .conf files it happens to pass through untouched, which is why this bug hides for months:

$ mkdir -p /tmp/g && touch /tmp/g/a.conf /tmp/g/b.conf
$ cd /tmp/g && ls
a.conf  b.conf
$ find /etc -name *.conf
find: paths must precede expression: `b.conf'
find: possible unquoted pattern after predicate `-name'?

(cd /tmp/g && ls: && runs the second command only if the first worked, 1.7.) bash turned it into find /etc -name a.conf b.conf. With exactly one match it is worse: find /etc -name a.conf runs, succeeds, and silently searches for the wrong thing. -name '*.conf' with quotes, every time.

-size and -mtime round, and that bites

find rounds up to the unit before comparing. -size -1M means "rounds up to less than 1 MiB" - and every file from 1 byte to 1 MiB rounds up to 1, so -size -1M only matches empty files. For small thresholds, use a smaller unit:

-size -1M      only empty files (surprise)
-size -1024k   under 1 MiB, what you meant
-size +1G      strictly bigger than 1 GiB

-mtime counts whole 24-hour periods, discarding the fraction:

-mtime 0       modified within the last 24 hours
-mtime +7      at least 8 full days ago (age of 7 days 23 hours does NOT match)
-mtime -7      less than 7 days ago

That off-by-one is why retention jobs written as -mtime +7 keep eight days of files. It is fine once you know it; write -mtime +6 if you mean "older than a week" and say so in a comment.

-perm: three forms

-perm 644      mode is EXACTLY 644
-perm -4000    ALL of these bits are set       (setuid, whatever else)
-perm -o+w     same thing, symbolic            (world-writable)
-perm /111     ANY of these bits is set        (executable by someone)

The audit one-liners:

sudo find / -xdev -perm -4000 -type f          setuid binaries
sudo find / -xdev -perm -o+w -type f           world-writable files
sudo find / -xdev \( -nouser -o -nogroup \)    orphaned ownership

Operators, and the precedence trap

Tests next to each other are joined by an implicit -a (AND). -o is OR, ! or -not negates, and \( \) groups (escaped, because the shell owns parentheses). AND binds tighter than OR:

$ find /etc -maxdepth 1 -type d -name 's*' -o -name 'host*'
/etc/hostname                <- files! -type d only applied to the left side
/etc/hosts
/etc/ssh
/etc/sudoers.d
...
$ find /etc -maxdepth 1 -type d \( -name 's*' -o -name 'host*' \)
/etc/ssh
/etc/sudoers.d
...

The first reads as (-type d AND -name s*) OR (-name host*). When you mix -o with anything else, parenthesise.

Global options go first

-maxdepth, -mindepth and -xdev are not tests - they change the walk. Put them after a test and find warns (and applies them anyway):

$ find /etc -name '*.conf' -maxdepth 1
find: warning: you have specified the global option -maxdepth after the argument -name, but global options are not positional, ...

-xdev is the one that matters in an incident: without it, find / walks into /proc, /sys, NFS mounts and every other filesystem.

Actions

-print                 the default
-printf '%s %p\n'      custom: %s size, %p path, %f name, %u user, %m octal mode,
                       %TY-%Tm-%Td modification date
-ls                    ls -dils style
-delete                delete what matched (implies -depth: children before parents)
-exec cmd {} \;        run cmd once PER FILE, {} replaced by the path
-exec cmd {} +         run cmd with MANY paths at once - much faster
                       (\; is backslashed so the shell passes ; to find)
$ find /home -name .bashrc -exec ls -l {} \;
-rw-r--r-- 1 learner learner 615 Sep 14 17:43 /home/learner/.bashrc
$ sudo find /var/log -name '*.gz' -mtime +30 -exec rm {} +

Twelve thousand files with \; is twelve thousand processes; with + it is a handful. Use \; only when the command can take one path at a time and you need that.

-delete: print first, then delete

sudo find /srv/cache/sessions -type f -mtime +7            # read the list
sudo find /srv/cache/sessions -type f -mtime +7 | wc -l    # how many
sudo find /srv/cache/sessions -type f -mtime +7 -delete    # then, and only then

Two ways -delete ruins a day:

Errors, and silencing only the ones you mean

$ find / -name passwd
/usr/bin/passwd
/etc/passwd
find: '/etc/sudoers.d': Permission denied
find: '/var/lib/app': Permission denied

2>/dev/null hides the noise, and also hides real errors. Running as root (sudo find) is usually the better fix. And note that any unreadable directory makes find exit 1 even though it printed results - check that before using find's exit code in a script.

What you can now do

Why it helps

find is the tool for every cleanup job, retention policy, security audit and "where is that file" question: deleting logs older than 30 days, finding world-writable files or setuid binaries, locating files owned by a removed user, clearing old session files when rm * says "Argument list too long". It shows up in cleanup scripts and systemd timers you will write and review.

Its traps cause real outages: -delete placed before the tests deletes everything, an unquoted glob searches for the wrong thing, -mtime +7 keeps eight days, -size -1M only matches empty files, and -o without parentheses matches far more than intended. Knowing them, and the print-before-delete habit, is what makes your retention jobs safe to merge.

Commands in this lesson

find mkdir cd

FAQ

Why must I quote the pattern in -name?

An unquoted *.conf is a glob, and the shell expands it against the current directory before find runs. If files match, find receives their names instead of the pattern, which either causes "paths must precede expression" or silently searches for one specific name. If nothing matches, bash passes the pattern through and it happens to work, which hides the bug. Always write -name '*.conf'.

What is the difference between -exec {} \; and -exec {} +?

With \;, find runs the command once per matching file, so twelve thousand files mean twelve thousand processes. With +, find collects as many paths as fit on one command line and runs the command a few times with many arguments, like xargs, which is dramatically faster. Use \; only when the command accepts a single path or {} must appear in the middle of the arguments.

Why does -mtime +7 not match a file that is 7.5 days old?

-mtime counts whole 24-hour periods and discards the fraction. A file 7.5 days old has an age of 7 periods, and +7 means "more than 7", so it needs 8. In practice -mtime +7 matches files at least 8 days old. Write -mtime +6 if you mean "older than a week", or use -mmin or -newermt '7 days ago' for exact thresholds.

Is find -delete safe?

It is precise, but unforgiving. Always run the same command with -print first, check the count and list, then switch to -delete. Keep -delete last, because actions are evaluated in order and a -delete before the tests deletes everything. Double-check start points for stray spaces, restrict with -type f and -xdev, and prefer an absolute path to the exact directory.

Why does find exit 1 even though it printed results?

find returns non-zero if it encountered any error during the walk, such as a directory it could not read, even when matches were found and printed. With set -e or a check on its exit code, that can fail a script unexpectedly. Run as a user who can read the tree, restrict the search with -xdev and specific paths, or handle the status explicitly.

In an interview Junior

How would you delete log files older than 30 days in /var/log/app, safely?

Print first, then delete:

sudo find /var/log/app -type f -name '*.log*' -mtime +30 -print
sudo find /var/log/app -type f -name '*.log*' -mtime +30 | wc -l
sudo find /var/log/app -type f -name '*.log*' -mtime +30 -delete

find also does not hit "Argument list too long" the way rm * does, because it never builds a list of names.

Also asked: How do you find all files larger than 1 GB on one filesystem? · Why does find /etc -type d -name 's*' -o -name 'host*' also print files? · What is the difference between -exec cmd {} \; and -exec cmd {} +?

Practise this lesson in the terminal Free, in your browser - a real Ubuntu terminal to try it in, with missions that check your work.