Globbing, find and xargs · about 50 min

find: tests and actions

Why this matters

Globs select files in one directory (or, with **, by name only). Real questions are richer: which log files under /var are bigger than 100 MB? which files did someone change in the last day? which directories are empty? delete every .pyc file in this tree, but not in the virtualenv. find answers all of these. It walks a directory tree and evaluates an expression for every entry, and that expression can print, delete or run commands. It is one of the most useful and most misused Unix tools; the misuse almost always comes from not knowing how the expression is evaluated, which is the heart of this lesson.

The shape of a find command

find  [starting points...]  [expression]
      project src/         -name '*.c' -size +10k -print

find visits each starting point and everything below it, depth first, and for each entry evaluates the expression from left to right. The expression is made of:

With no action at all, find adds -print, so find project -name '*.c' prints matching paths. With no expression, it prints everything:

$ find project
project
project/src
project/src/main.c
project/src/util.h
...

Paths start with the starting point as you wrote it: find . prints ./src/main.c, find project prints project/src/main.c, find /work/project prints absolute paths.

Evaluation: short-circuit, left to right

For each entry, find evaluates test1 test2 test3 as test1 -a test2 -a test3, stopping at the first false. This is the same short-circuit logic as && and || in the shell (module 1):

find project  -type f   -name '*.conf'   -size +1k   -print
              ───────   ──────────────   ─────────   ──────
for project/config/app.conf:  true  →  true  →  true  →  print (true)
for project/src:              false → (stop; nothing else is evaluated)
for project/src/main.c:       true  →  false → (stop)

Two consequences:

  1. Order affects speed (cheap tests like -name before expensive ones like -exec), and order affects behavior when actions are involved: -print before a test prints entries whether or not they pass the test.
  2. -a binds tighter than -o. A -o B -exec ... means A -o (B -a -exec ...), as the first quiz shows. Group with escaped parentheses:
find . \( -name '*.txt' -o -name '*.md' \) -print

The parentheses must be quoted (\( or '(') because they are special to bash, and they must be separate words, with spaces around them.

The common tests

TestTrue when
-name 'pat'the base name matches the glob (use -iname for case-insensitive)
-path 'pat'the whole path matches (* matches / here)
-type f / d / lregular file / directory / symbolic link
-size +10klarger than 10 KiB (- smaller, no sign: exactly; units c bytes, k, M, G)
-emptyempty file or empty directory
-mtime -7modified less than 7 days ago (+7: more than 7; units of 24 hours)
-mmin -30modified less than 30 minutes ago
-newer refmodified more recently than the file ref
-perm -u+xhas (at least) user-execute permission
-user name, -group nameowned by
-maxdepth N(an option) do not descend more than N levels; put it first

Always quote the pattern in -name. Unquoted, the shell expands it first:

$ find . -name *.c            # in a directory containing a.c and b.c
find: paths must precede expression: `b.c'

bash turned it into find . -name a.c b.c. Worse, if exactly one .c file is in the current directory, it silently searches for that one name only.

Sizes and times are rounded

-size +1k compares the size rounded up to whole KiB units, so a 10-byte file counts as 1k and is not +1k; use c (bytes) for precision: -size +1024c. -mtime +1 means "more than one whole 24-hour period ago", that is, at least 48 hours. These rounding rules surprise everyone once; when precision matters, use bytes, minutes, or -newer with a reference file made by touch -d '2026-10-01' /tmp/ref.

Actions

ActionEffect
-printprint the path and a newline (the default)
-print0print the path and a NUL byte (lesson 3)
-printf 'fmt'formatted output: %p path, %P path without the starting point, %f base name, %s size, %TY-%Tm-%Td date, \n newline
-lsan ls -l style line
-deletedelete the entry (implies -depth)
-exec cmd {} \;run cmd once per entry, {} replaced by the path
-exec cmd {} +run cmd with as many paths as fit, like xargs
-execdir cmd {} \;like -exec, but run from the entry's directory
-prunedo not descend into this directory
-quitstop immediately
$ find project/config -type f -printf '%s\t%P\n'
2000    app.conf
8    small.conf
5000    sub/big.conf

-exec in detail

find . -name '*.log' -exec gzip {} \;     # gzip a.log; gzip b.log; ... one process each
find . -name '*.log' -exec gzip {} +      # gzip a.log b.log c.log ...  few processes

The ; ends the command and must be escaped (\; or ';') because it is special to bash. {} is replaced by the path by find, without any shell involved, so names with spaces arrive as single arguments; no quoting needed or possible. That is exactly why find's -exec is safe and for f in $(find ...) is not.

With +, find collects paths and runs the command as few times as possible, which is much faster for thousands of files. The {} must then be the last thing before +. If your command needs the path in the middle, or needs shell features, run a small shell:

find . -name '*.jpg' -exec sh -c 'for f; do mv -- "$f" "${f%.jpg}.jpeg"; done' sh {} +

The paths become the inner shell's positional parameters (for f loops over "$@"); the lone sh fills $0.

-exec is also a test: it is true when the command exits 0. find . -type f -exec grep -q TODO {} \; -print prints only files containing TODO.

Pruning

To skip a whole directory, match it and -prune it, then -o the real work:

find . -path ./node_modules -prune -o -name '*.js' -print

Read it as: "if this is ./node_modules, prune it (and the result is true, so the -o side is skipped); otherwise, if the name matches, print it". The explicit -print matters: without it the default -print would apply to the whole expression and print ./node_modules itself.

-delete implies -depth (children are processed before their directory, so directories can be deleted after emptying them), and with -depth -prune has no effect. GNU find warns about the combination. With pruning, delete through -exec rm {} +, or exclude with ! -path './keep/*'.

Safety habits

  1. Run with -print first, read the list, then replace it with -delete or -exec rm.
  2. Put -delete last. find . -delete -name '*.tmp' deletes everything: -delete runs before the name test for every entry.
  3. Add -type f when you mean files, so a directory named x.bak is not touched.
  4. Restrict depth with -maxdepth when you only mean the top level.

Permission errors

Searching places you cannot read produces lines like find: '/root': Permission denied on stderr. As the sandbox user you will see them if you search from /. Silence them with 2>/dev/null only after you know that is all they are.

Common mistakes

Exercises

Open the shell for this lesson: /work/project is a small tree with source files, configs, backups and a keep directory. Two tasks use it (the second deletes things, so do the first one first), then a quiz on precedence and a script using -printf.

Recap

Going further: find --help lists every test and action; the -printf directives are in man find once installed. Try find / -xdev -type f -size +10M 2>/dev/null to find the large files in the sandbox's root filesystem.

Exercises

Exercise 1 · quiz

Which files are removed?

Someone wants to delete all .txt and .md files and runs:

find . -name '*.txt' -o -name '*.md' -exec rm {} \;

What happens?

Answer
  • Only the .md files are removed; the .txt files are just matched and nothing is done with them.

Between tests, an implicit -a (and) binds tighter than -o (or), so find reads it as -name '*.txt' -o ( -name '*.md' -a -exec rm {} ; ). For a .txt file the left side is true, so the right side, including the -exec, is skipped. And because the expression contains an action, find does not add its default -print either, so .txt files are silently ignored. The fix is parentheses, which must be escaped or quoted for the shell: find . \( -name '*.txt' -o -name '*.md' \) -exec rm {} +.

Exercise 2 · task in a shell

Find by name and size

/work/project is a small source tree. Use find to list every file under project whose name ends in .conf and which is larger than 1 KiB. Save the paths, exactly as find prints them when started with find project ... (so they begin with project/), sorted, one per line, into /work/bigconf.txt.

Hint 1

Two tests in a row are combined with 'and': find project -name '*.conf' -size ....

Hint 2

-size +1k means more than 1 KiB. Quote the pattern so the shell does not expand it.

Hint 3

find project -name '*.conf' -size +1k | sort > bigconf.txt

Reference solution
find project -name '*.conf' -size +1k | sort > bigconf.txt

Exercise 3 · task in a shell

Delete backups, but not in keep/

Delete every regular file ending in .bak anywhere under /work/project, except those inside project/keep, which must survive. The directory project/dir.bak is a directory, not a file, and must also survive. One file name contains a space.

Use a single find command. Run it with -print instead of the deleting action first to see what it would hit.

Hint 1

-type f restricts to regular files. To skip a directory, use -path project/keep -prune -o ....

Hint 2

-delete turns on depth-first order, in which -prune does nothing, so use -exec rm {} + with -prune, or exclude with ! -path 'project/keep/*'.

Hint 3

find project -path project/keep -prune -o -type f -name '*.bak' -exec rm {} +

Reference solution
find project -path project/keep -prune -o -type f -name '*.bak' -exec rm {} +

Exercise 4 · write code

Report empty files

Write script.sh DIR that prints the paths of all empty regular files anywhere under DIR, relative to DIR (so without the DIR/ prefix), sorted, one per line.

If DIR is not a directory, print not a directory: DIR to stderr and exit with status 1.

With the checker's directory tree, script.sh tree must print exactly:

a.txt
deep/er/z.log
sub/c d.txt

Hint: find's -printf action can print a path relative to the starting point.

File: script.sh

Starting point

find "$1" -empty

Example with arguments tree

expected output

a.txt
deep/er/z.log
sub/c d.txt

Example with arguments nowhere

expected output

(nothing)
Hint 1

-type f -empty selects empty regular files (an empty directory is also -empty).

Hint 2

-printf '%P\n' prints the path with the starting point removed. Pipe into sort.

Reference solution
if [ ! -d "$1" ]; then
  echo "not a directory: $1" >&2
  exit 1
fi
find "$1" -type f -empty -printf '%P\n' | sort