We closed Module 4 by saying that daily-report.sh still lacks robustness, and we promised to start right here: find, xargs, tar and safe temporary files. It is no accident that robustness begins with files. Almost everything an operations script gets wrong, it gets wrong when choosing which files to act on —it deletes too much, it skips a directory, it chokes on a name containing a space— or when writing to a temporary file that another process can clobber. In this lesson you will learn to select files with surgical precision, to act on them without a strange name breaking anything, and to create temporary and compressed files that do not turn into a security hole.
Contents
- Why globbing is not enough
- Anatomy of
find - Selection criteria
- Combining criteria:
-a,-o,!and parentheses -maxdepthand-mindepth: why they come first- Actions:
-print,-delete,-execand-execdir - Names with spaces:
-print0andxargs -0 xargsin depth- Safe temporary files with
mktemp - Archiving and compressing:
tar,gzip,zcat,zgrep - Hard and symbolic links
rsyncin two lines
- Why globbing is not enough
In 02-05 you saw that *.log is expanded by the shell before the command starts, and that with shopt -s globstar even **/*.log walks subdirectories. So why do we need find at all?
| Need | Globbing? | find |
|---|---|---|
| Files by name in one directory | Yes | Yes |
| Walking subdirectories | With globstar |
Native |
| Filtering by date, size, permissions, owner | No | Yes |
| Telling file / directory / link apart | With tricks | -type |
| Limiting the depth | No | -maxdepth |
| Thousands of results | Fails: Argument list too long |
No limit |
That last point is what kills scripts in production: globbing builds a command line containing every name, and the kernel rejects it beyond roughly two megabytes. find processes files one at a time. The rule of thumb: globbing for a handful of known files in one directory; find to search by criteria or walk trees.
- Anatomy of
find
findThree parts, always in this order: where to search, what to select and what to do with what it finds. If you omit the action, find assumes -print. If you omit the criteria, it shows everything under the path.
Two details that cause surprises from day one:
- The pattern
'*.log'is quoted. Without quotes, the shell would expand it before launchingfind, using the files in the current directory, andfindwould receive something it did not expect. It is the same quoting principle as 03-06: here the pattern is forfind, not for the shell. findwalks recursively from the given path.find .can take minutes on a large tree.
- Selection criteria
find /srv/veloz/data -name 'shipments*.csv' # by name, case sensitive
find /srv/veloz/data -iname 'SHIPMENTS*.CSV' # -i: ignores case → finds shipments.csv
find /srv/veloz -path '*/archive/2026/*' # the pattern applies to the whole PATH
find /var/log/veloz -type f # regular files only
find /srv/veloz -type d -name 'archive' # directories only
find ~/veloz-ops -type l # symbolic links onlyWatch out for the difference between -name and -path: -name compares only the final name (the basename from 04-04), whereas -path compares the whole path exactly as find generates it, so its * do cross slashes.
The size and time criteria are what make find irreplaceable:
| Criterion | Meaning | Example |
|---|---|---|
-size +100M |
larger than 100 MiB (k, M, G; c = bytes) |
runaway logs |
-size -1k |
smaller than 1 KiB | empty reports |
-mtime +30 |
modified more than 30 days ago | archive purge |
-mtime -1 |
modified in the last 24 h | today's report |
-mmin -15 |
modified in the last 15 minutes | monitoring |
-newer f |
newer than file f |
changes since the last report |
-user joan |
owned by that user | auditing |
-perm 600 |
permissions exactly 600 | checking veloz-ops.conf |
-perm -o=w |
writable by others (danger) | security audit |
-empty |
empty file or directory with no content | cleanup |
The sign matters a great deal: + is "more than", - is "less than", and no sign means exactly. A -mtime 30 means "modified on day 30 counting backwards", almost never what you want. And -mtime counts in 24-hour blocks, truncating: for fine-grained windows, use -mmin.
find ~/veloz-ops/logs -type f -name 'report-*.txt' -mtime +30
# /home/joan/veloz-ops/logs/report-2026-06-15.txt
# /home/joan/veloz-ops/logs/report-2026-06-14.txtThere is the purge of old reports the toolkit needs: every report older than a month.
- Combining criteria:
-a, -o, ! and parentheses
-a, -o, ! and parenthesesWhen you put two criteria one after another, find joins them with an implicit AND. For everything else there are operators:
find ~/veloz-ops/logs -name '*.txt' -o -name '*.log' # logical OR
find /var/log/veloz -type f ! -name '*.gz' # NOT: the uncompressed ones
find /srv/veloz -type f \( -name '*.csv' -o -name '*.tsv' \) -mtime +90The parentheses are escaped (\\( and \\)) or quoted ('('), because to the shell unescaped parentheses mean a subshell. And they are essential as soon as you mix -o with another criterion, because -a binds tighter than -o: without parentheses, that last example would mean "the .csv files (of any date) or the .tsv files older than 90 days".
This mistake is especially serious combined with -delete. Always check with -print before replacing it with a destructive action.
-maxdepth and -mindepth: why they come first
-maxdepth and -mindepth: why they come firstfind /srv/veloz/data -maxdepth 1 -name '*.csv' # top level only
find /srv/veloz/data/archive -mindepth 2 -maxdepth 2 -type d # the YYYY/MM ones-maxdepth 0 is the starting path itself; 1 is its direct children. -mindepth 1 excludes the starting path, handy for emptying a directory without deleting it.
These two options must come before any other criterion. Technically they are not criteria but global options: they affect the whole traversal, not one particular file. If you write find . -name '*.log' -maxdepth 1, find warns with warning: -maxdepth is a global option and applies it anyway, but it is a sign that you have not understood the ordering, and one day it will bite you with options that are less forgiving.
- Actions:
-print, -delete, -exec and -execdir
-print, -delete, -exec and -execdirfind ~/veloz-ops/logs -name 'report-*.txt' -mtime +30 -print # list (default)
find ~/veloz-ops/logs -name 'report-*.txt' -mtime +30 -delete # delete
find /var/log/veloz -name '*.log.[0-9]' -exec gzip {} \; # run per file
find /var/log/veloz -name '*.log.[0-9]' -exec gzip {} + # run per batch-delete is safer and faster than -exec rm {} \; because it does not spawn processes and has no trouble with strange names, but it must go last: find . -delete -name '*.txt' deletes the whole tree before looking at the name.
The difference between \; and + is the one that costs the most performance to ignore. The {} is the placeholder where find inserts the file name, and the terminator decides how they are grouped:
| Terminator | Invocations | With 5,000 files | When to use it |
|---|---|---|---|
\; |
One per file | 5,000 processes (~30 s) | The command only accepts one argument, or you want the individual exit code |
+ |
One per batch (thousands of arguments) | 2-3 processes (<1 s) | Almost always: grep, gzip, rm, chmod |
The \; is escaped for the same reason as the parentheses: unescaped, the ; ends the command as far as the shell is concerned.
-execdir is the safe variant —find /srv/veloz/data -name '*.tmp' -execdir rm -- {} \;—: it runs the command inside the directory where each file lives and passes it ./name instead of the full path. That prevents attacks in which someone renames a directory halfway through the traversal, and stops a file called -rf from being interpreted as an option.
- Names with spaces:
-print0 and xargs -0
-print0 and xargs -0This is the classic that separates people who copy recipes from people who understand the shell: find ~/veloz-ops/logs -name '*.txt' | xargs rm is broken.
find prints one name per line and xargs splits on whitespace. A file called report july.txt arrives as two arguments: xargs tries to delete report and july.txt. A name containing a newline is even worse. The canonical solution uses the one byte that cannot appear in a file name, the null:
-print0 separates with \0 instead of \n; -0 tells xargs to expect that separator. Never pipe find into xargs without that pair. If the command allows it, -exec ... + does the same thing without a pipe and without the risk.
xargs in depth
xargs in depthxargs reads from standard input and builds command lines out of what it reads. Its useful options:
| Option | Effect |
|---|---|
-0 |
Null separator (always, with find -print0) |
-n N |
At most N arguments per invocation |
-I{} |
Substitutes {} for each item, one at a time (implies -n 1) |
-P N |
Runs up to N invocations in parallel |
-t |
Prints the command to stderr before running it (debugging) |
-r |
Run nothing if the input is empty (GNU; vital in scripts) |
# Compress the archive CSVs, 4 at a time, showing what it does
find /srv/veloz/data/archive -name '*.csv' -print0 \
| xargs -0 -r -t -P 4 -n 1 gzipWithout -r, if the find matches nothing, xargs runs gzip with no arguments and it sits waiting on standard input: the script hangs with no explanation. It is a real and frequent failure in scheduled tasks.
-I{} is for when the name does not go at the end of the command —xargs -0 -I{} cp -- {} /srv/veloz/backup/—, but it does not batch: one invocation per file, with the cost you already know. Use it only when the argument position demands it.
- Safe temporary files with
mktemp
mktempdaily-report.sh needs an intermediate file to sort the table before printing it. The temptation is tmp=/tmp/report.$$, and it is wrong: $$ is the PID, which is neither secret nor unpredictable. /tmp is writable by anyone. Two classic attacks: the race condition (an attacker creates the file between your check and your write) and the symlink attack (they create /tmp/report.12345 pointing at ~/.ssh/authorized_keys, and your script writes there with your permissions). The solution is to delegate to the system:
tmp=$(mktemp) # /tmp/tmp.aX9k2Lp0
tmpdir=$(mktemp -d) # directory, permissions 700
tmp=$(mktemp -t daily-report.XXXXXX) # with a recognizable prefix
tmp=$(mktemp -p ~/veloz-ops/logs rep.XXXXXX) # in a specific directorymktemp creates the file atomically, with a random name and permissions 600 (700 for directories). There is no race window and nobody else can read it. The XXXXXX (six minimum) is where the random part is inserted.
The other half is missing: deleting it no matter what, including if the script dies halfway through. That is done with trap, and it is the opening line of 05-03: tmp=$(mktemp); trap 'rm -f "$tmp"' EXIT. Remember the pair: whoever creates a temporary file schedules its cleanup on the very next line.
- Archiving and compressing:
tar, gzip, zcat, zgrep
tar, gzip, zcat, zgreptar groups many files into one (.tar); gzip compresses a single file. Together they give the good old .tar.gz:
cd /srv/veloz/data # key: we position ourselves at the root of the tree
tar -czf ~/veloz-ops/logs/archive-2026-07.tar.gz archive/2026/07
tar -tzf ~/veloz-ops/logs/archive-2026-07.tar.gz | head -3 # list without extracting
tar -xzf ~/veloz-ops/logs/archive-2026-07.tar.gz -C /tmp/restore # extractThe letters: c create, x extract, t list, z pipe through gzip, f target file, -C change directory before acting. j uses bzip2 and J uses xz (slower, compresses more).
Always use relative paths. If you archive /srv/veloz/data/archive, tar warns with Removing leading '/' and, in old versions or with the -P option, extraction would write directly over /srv/veloz/data on any machine, trampling whatever was there. With relative paths you decide where to restore using -C. The correct pattern is the preceding cd (or tar -C /srv/veloz/data -czf ... archive/2026/07).
--exclude accepts globs and can be repeated. And for the rotated logs in /var/log/veloz you do not need to decompress anything:
tar -czf backup.tar.gz --exclude='*.tmp' --exclude='logs/*' veloz-ops
zcat /var/log/veloz/access.log.2.gz | wc -l # like cat, but for .gz
zgrep -c ' 500 ' /var/log/veloz/access.log.*.gz # like grep, over compressed files
gzip -d access.log.2.gz # decompresses and DELETES the .gz
gzip -k access.log.1 # -k keeps the originalzgrep over the rotated files is what lets daily-report.sh answer "how many 500 errors were there last week?" without using extra disk space.
- Hard and symbolic links
ln /srv/veloz/data/shipments.csv /srv/veloz/data/shipments-today.csv # hard
ln -s /srv/veloz/data/shipments.csv ~/veloz-ops/shipments-current.csv # symbolic
readlink -f ~/veloz-ops/shipments-current.csv # /srv/veloz/data/shipments.csv
find ~/veloz-ops -type l ! -exec test -e {} \; -print # broken linksHard link (ln) |
Symbolic link (ln -s) |
|
|---|---|---|
| What it is | Another name for the same data | A file containing a path |
| Across disks | No | Yes |
| To directories | No | Yes |
| If you delete the original | The data stays alive | The link is left broken |
ls -l shows |
A normal file | link -> target |
In practice: symbolic for 95% of cases (pointing at the "latest report", switching the active version), hard for cheap copies within the same filesystem. readlink -f resolves the whole chain down to the real path, and it is the reliable way to know what something points at.
rsync in two lines
rsync in two linesrsync -a --delete source/ target/ synchronizes trees by copying only what has changed, works over SSH and is the right tool for incremental backups. Its natural home is 07-03; for now just remember that it exists and that the trailing slash on the source changes the meaning.
Common Mistakes and Tips
- Forgetting the quotes in
-name '*.log'. The shell expands the pattern first andfindsearches for something else. -deletebefore the criteria. It deletes the entire tree. Always put it last and test first with-print.find ... | xargswithout-print0/-0. One space in a name and you delete what you should not have.xargswithout-r. With empty input it runs the command with no arguments: a hang or a disaster.-owithout parentheses. The precedence of-asilently changes the meaning of the expression.-mtime 30with no sign. It means "exactly day 30", not "more than 30 days".tarwith absolute paths. Restoring tramples the original system. Usecdor-Cand relative paths.- Tip: build destructive
findcommands in two steps. First with-print, you review the list, and only then do you change the action. In a script, save the list into amktemptemporary file and act on that.
Exercises
Exercise 1. Write a function purge_reports() that deletes from ~/veloz-ops/logs the report-*.txt files older than 30 days and the empty files of any age, reporting how many it deleted. It must work with names containing spaces.
Exercise 2. Archive last month's history (/srv/veloz/data/archive/YYYY/MM/) into ~/veloz-ops/logs/archive-YYYY-MM.tar.gz excluding the .tmp files, verify that the archive can be listed and only then delete the originals.
Exercise 3. Count how many requests with code 500 there are in all the access.log* files in /var/log/veloz, including the compressed ones, using a safe temporary file for the intermediate result.
Solutions
Solution 1.
purge_reports() {
local dir="${1:-$HOME/veloz-ops/logs}" n=0
while IFS= read -r -d '' f; do
rm -- "$f"; (( ++n ))
done < <(find "$dir" -maxdepth 1 -type f \
\( -name 'report-*.txt' -mtime +30 -o -empty \) -print0)
printf 'Purged %d files from %s\n' "$n" "$dir"
}read -d '' reads up to the null byte, which is exactly what -print0 emits: the precise counterpart of the pattern from section 7. The parentheses group the condition "(old AND with that name) OR empty", and -maxdepth 1 stops the purge from sneaking into subdirectories. The -- before "$f" protects against names starting with a dash.
Solution 2.
month=$(date -d 'last month' +%m); year=$(date -d 'last month' +%Y)
target=~/veloz-ops/logs/archive-$year-$month.tar.gz
tar -C /srv/veloz/data --exclude='*.tmp' -czf "$target" "archive/$year/$month"
if tar -tzf "$target" > /dev/null; then
find "/srv/veloz/data/archive/$year/$month" -type f -delete
printf 'Archived and purged %s/%s\n' "$year" "$month"
else
printf 'ERROR: archive %s is not readable, nothing is deleted\n' "$target" >&2
fiThe date -d 'last month' comes from 04-06. The order is non-negotiable: verify before deleting. The tar -tzf to /dev/null shows nothing but returns a non-zero code if the archive is corrupt, and the if exploits that exactly as in 03-04.
Solution 3.
tmp=$(mktemp -t veloz500.XXXXXX)
zgrep -h ' 500 ' /var/log/veloz/access.log* > "$tmp"
printf 'Total 500 errors: %d\n' "$(wc -l < "$tmp")"
cut -d' ' -f7 "$tmp" | sort | uniq -c | sort -rn | head -5 # worst affected paths
rm -f "$tmp"zgrep handles plain files and .gz files alike, and -h suppresses the file-name prefix so that cut always sees the same column structure. The final rm is correct, but it only runs if the script makes it there alive: in 05-03 you will replace it with a trap that guarantees cleanup even in the face of a failure.
Conclusion
find is the search tool that globbing cannot replace: it selects by name, path, type, size, date, owner, permissions or emptiness, combines criteria with -a, -o, ! and escaped parentheses, bounds the traversal with -maxdepth/-mindepth —which come first because they are global options— and acts with -print, -delete, -exec ... \;, -exec ... + (thousands of times cheaper) or -execdir. When you do need a pipe, the pair -print0 with xargs -0 is mandatory, and -r, -n, -I{}, -P and -t round out the toolkit. mktemp and mktemp -d create atomic, private and unpredictable temporary files, instead of the /tmp/file.$$ that invites others to clobber you. tar with relative paths archives, and zcat/zgrep read the rotated logs without decompressing them. And links, hard or symbolic, give alternative names to what already exists.
With this, daily-report.sh now knows how to choose which files it works on and where it leaves its intermediates. But an operations script does not only touch files: it launches processes, and sometimes several at once. Is veloz-api alive before we ask it for data? Can I compute the four cities in parallel instead of one after another? What happens if the 6:00 report is still running when the 6:05 one starts? In 05-02 we get into process management: PIDs, ps, pgrep, background jobs, wait, signals, timeout and the locking with flock that stops two reports from stepping on each other.
Bash Programming Course
Module 1: Introduction to Bash
- What Is Bash?
- Setting Up Your Environment
- Basic Command Line Navigation
- Understanding the Shell
- Finding Help: man, help and --help
Module 2: Basic Bash Commands
- File and Directory Operations
- Text Processing Commands
- File Permissions and Ownership
- Redirection and Piping
- Wildcards and Path Expansion
- History and Keyboard Shortcuts
Module 3: Scripting Fundamentals
- Creating and Running a Script
- Variables and Constants
- Basic Operators
- Conditional Statements
- Arguments and User Input
- Quoting, Expansion and Substitution
Module 4: Intermediate Scripting
- Loops in Bash
- Functions in Bash
- Arrays and Associative Arrays
- String Manipulation
- The case Statement and Interactive Menus
- Arithmetic and Numeric Calculations
Module 5: Advanced Scripting Techniques
- Advanced File Operations
- Process Management
- Error Handling and Debugging
- Regular Expressions
- Advanced I/O: Descriptors and Here-Documents
- Modular Scripts and Reusable Libraries
Module 6: Working with External Tools
Module 7: Automation and Scheduling
- Cron Jobs
- Automating Tasks
- Backup and Restore Scripts
- Monitoring and Logging
- Services and Timers with systemd
- Remote Automation with SSH
Module 8: Best Practices and Optimization
- Writing Readable Code
- Optimizing Bash Scripts
- Security Considerations
- Version Control with Git
- Static Analysis with ShellCheck and shfmt
- Automated Testing with Bats
- Portability: POSIX sh versus Bashisms
