Shell Lesson 18 of 42

File Operations at Scale: rsync, find -print0, Atomic Writes & Parallel-Safe Patterns — When `cp -r` Stops Being Enough

In a nutshell

Think about how a museum swaps the notice in a glass display frame. It does not rub out the old text and rewrite it while visitors are reading — for a few seconds the sign would be half-erased and unreadable. Instead a fresh notice is printed at the back office, and then, in one quick motion, the old card is lifted out and the new one dropped in. Anyone glancing at the frame sees either the whole old notice or the whole new one, never a smudged half-and-half. That single motion — build the new copy off to the side, then swap it in atomically — is the heart of an atomic write, and on a Unix filesystem the “one quick motion” is a single rename(2) system call.

This lesson is about doing file work that survives the real world: gigabytes of data, directories with thousands of files, filenames containing spaces and newlines, transfers over flaky networks, and processes that might be killed half-way through. Three ideas carry most of the weight. Atomic writes swap a whole new file into place so a reader or a crash never catches a half-written one. rsync is a courier that only ships the pages that actually changed and can pick up exactly where it left off if the van breaks down — which is why it beats cp for anything big, remote, or repeated. And NUL-safe iteration (find -print0 piped to xargs -0) labels every file with a barcode the shell cannot mis-read, so a file called Q1 report (final).pdf is one item, not three words.

The reason atomic writes work comes down to one filesystem guarantee: renaming a file on the same filesystem is a single, indivisible step. The kernel swaps one directory entry to point at the new data; there is no in-between state where the name resolves to a half-written file. Miss the “same filesystem” part and the guarantee evaporates — a mv across two mounts quietly becomes copy-then-delete, which any crash can tear in half. By the end you will reach for mktemp + rename without thinking, drive rsync for 1 TB volumes and rolling snapshots, iterate over the ugliest filenames safely, and know exactly which of your tools are GNU-only so your scripts do not break the day they meet a Mac or a BusyBox container.

Level: Advanced · Time: ~40 min

Before this lesson you should be comfortable with quoting and word-splitting (IFS), basic find/xargs, and the difference between hard links and symlinks — see Variables, quoting & IFS and Filesystem semantics: hard links, symlinks & fsync. After this lesson you will be able to:

Atomic file writes: writing straight to the target truncates then refills it so a reader or crash catches a half-written file; the safe pattern stages a temp file in the target's own directory on the same filesystem, fsyncs the file and its directory, then rename(2)/mv swaps the directory entry in one atomic step so readers see the whole old or whole new file — while a cross-filesystem mv silently degrades to a non-atomic copy.

Read left → right: writing in place (red) truncates the target and can expose a half-written file, so instead you stage the new copy in a mktemp temp file on the same filesystem (blue), fsync the file and its directory for durability (amber), then rename(2)/mv swaps the directory entry in one atomic step (green) — readers with the file already open keep the old inode, and the one trap is a cross-filesystem mv, which is really copy-then-unlink and not atomic (red).


cp -r and for f in * work for tens of files. They break around the time you have:

This lesson covers the tools that handle all those cases:

By the end you’ll handle 1TB volumes confidently and never lose data to “the script died half-way through.”


1. rsync — the Unix copy tool you should be using

rsync is “smart cp”: it figures out what’s changed since last time and only transfers the differences. For local copies it’s competitive with cp. For remote copies it’s typically 10-100x faster on incremental transfers.

The canonical local copy

rsync -aP /source/ /dest/

The trailing / on source matters. rsync -a /src/ /dst/ copies the contents of /src into /dst. rsync -a /src /dst/ copies /src itself into /dst (creating /dst/src).

rsync -aP /src/ /dst/        # /src/foo → /dst/foo
rsync -aP /src  /dst/        # /src/foo → /dst/src/foo

This is the most common rsync mistake. Always think about whether you mean “copy contents” or “copy the dir itself.”

The canonical remote copy

rsync -azP /source/ user@host:/dest/

For very fast LAN transfers, omit -z (CPU is the bottleneck, not bandwidth).

--delete — make destination match source

rsync -aP --delete /source/ /dest/

Files in /dest/ that aren’t in /source/ are deleted. Use carefully — you can wipe the target if you fat-finger arguments. Always test with --dry-run first:

rsync -aPn --delete /source/ /dest/    # -n = --dry-run; just print what would happen

Always do this on first run with --delete.

--exclude and --include

rsync -aP --exclude='*.log' --exclude='node_modules/' /src/ /dst/

Patterns are checked against the path relative to the source root. --exclude='*.log' matches any .log file at any depth. --exclude='/cache/' matches only top-level cache/.

For complex rule sets, use --exclude-from=FILE:

# .rsync-excludes
node_modules/
.git/
*.log
*.tmp
__pycache__/
rsync -aP --exclude-from=.rsync-excludes /src/ /dst/

--link-dest — incremental snapshots

This is rsync’s killer feature. Hard-link files unchanged from a previous backup, only copying changed ones. Result: each “snapshot” appears full but actually shares disk with the previous.

# Yesterday's snapshot is at /backups/2026-06-21/
# Today, build /backups/2026-06-22/ that hard-links to yesterday's unchanged files
rsync -aP --link-dest=/backups/2026-06-21/ /source/ /backups/2026-06-22/

Now /backups/2026-06-22/ is a complete tree, but identical files share inodes with yesterday. Disk usage is roughly the size of new + modified files, not the full source.

This is how Time Machine, BackupPC, rsnapshot, and most “rolling N-day backup” systems work. Trivial to implement.

--partial and --inplace

If a transfer is interrupted, by default rsync deletes the partial file and starts over. With --partial (-P includes this), it keeps the partial. Re-running rsync resumes from the partial.

--inplace writes directly to the destination file rather than to a temp + rename. Faster, but readers may see partial data. Use only when readers won’t trip over half-files.

--bwlimit — rate limiting

rsync -aP --bwlimit=10000 /src/ user@host:/dst/    # 10,000 KB/s = 10 MB/s

Use during business hours so the rsync doesn’t saturate the link.

Verbosity and dry-run

rsync -aPv  /src/ /dst/         # verbose: list every file copied
rsync -aPvv /src/ /dst/         # extra verbose
rsync -aPn  /src/ /dst/         # dry run; show what WOULD be transferred

Always -n first when using --delete or aggressive exclude patterns.

--checksum vs default

By default, rsync skips files where size and mtime match. With --checksum it also reads and hashes both sides. Slow but bulletproof when you suspect mtimes are lying.

rsync over SSH with custom config

rsync -aP -e 'ssh -i ~/.ssh/backup_key -p 2222' /src/ user@host:/dst/

-e overrides the remote-shell command. Use to specify alternate keys, ports, or even different transports.

Combined real-world example: nightly backup

#!/usr/bin/env bash
set -Eeuo pipefail

source "$(dirname "${BASH_SOURCE[0]}")/lib/log.sh"

readonly SOURCE=/var/www
readonly DEST=/backups
readonly TODAY=$(date -u +%Y-%m-%d)
readonly YESTERDAY=$(date -u -d 'yesterday' +%Y-%m-%d 2>/dev/null || date -u -v-1d +%Y-%m-%d)

readonly TODAY_DIR="$DEST/$TODAY"
readonly YEST_DIR="$DEST/$YESTERDAY"

mkdir -p "$DEST"

OPTS=( -aP --delete --exclude-from=/etc/backup/excludes )
if [[ -d "$YEST_DIR" ]]; then
  OPTS+=( --link-dest="$YEST_DIR" )
fi

info "starting backup" date=$TODAY
rsync "${OPTS[@]}" "$SOURCE/" "$TODAY_DIR/"

# Prune backups older than 30 days
find "$DEST" -maxdepth 1 -type d -name '????-??-??' -mtime +30 -print0 \
  | xargs -0r rm -rf
info "backup complete"

This is the foundation of every “rolling daily backup” script. Adapt for your data. (The date -u -d 'yesterday' … || date -u -v-1d … line is the standard GNU-then-BSD fallback for “yesterday” — GNU date uses -d, BSD/macOS date uses -v-1d. We drill this in the Date & time arithmetic lesson.)


2. Filename-safe iteration (recap + new patterns)

We covered this in L4 and L6. Recap:

# WRONG — breaks on spaces/newlines in filenames
for f in $(find . -name '*.log'); do …; done

# RIGHT — NUL-separated, mapfile collects safely
mapfile -d '' -t FILES < <(find . -name '*.log' -print0)
for f in "${FILES[@]}"; do …; done

# RIGHT — for direct piping
find . -name '*.log' -print0 | xargs -0 -n 1 process

# RIGHT — read loop with explicit IFS=
find . -name '*.log' -print0 | while IFS= read -r -d '' f; do
  process "$f"
done

The IFS= (empty) before read prevents word-splitting. -d '' makes the delimiter NUL.

Why NUL specifically? A Unix filename may legally contain any byte except two: the path separator / and the NUL byte \0 (which C uses to terminate strings, so the kernel can never store it inside a name). NUL is therefore the only separator guaranteed never to appear inside a filename — which is exactly why -print0 / -0 / read -d '' are bulletproof where newline- or space-separated lists are not.

Portability note: mapfile/readarray needs bash 4.0+, and the -d (delimiter) option specifically needs bash 4.4+. macOS still ships bash 3.2, where mapfile does not exist at all (mapfile: command not found). For scripts that must run there, prefer the while IFS= read -r -d '' loop above — it works all the way back to bash 2. The full GNU-vs-BSD / old-bash minefield is mapped in Going deeper.

find ... -exec ...

For simple ops, skip xargs entirely:

# Per-file: forks once per file (slow if many files)
find . -name '*.log' -exec gzip {} \;

# Batched: forks once per batch (fast)
find . -name '*.log' -exec gzip {} +

Always prefer + over \; when the command supports multiple args. We covered this in L11.

find -execdir

find . -name '*.tmp' -execdir rm -- {} \;

-execdir runs the command in the directory containing the file. Useful for git/svn operations that act on cwd.

When **/ (globstar) is enough

For in-shell iteration where you don’t need find’s full power:

shopt -s globstar nullglob
for f in **/*.log; do
  process "$f"
done

Quote "$f" even when the glob match is “safe” — it’s a habit you don’t want to break.

parallel over find

find . -name '*.log' -print0 | parallel -0 -j 8 gzip {}

parallel -0 reads NUL-separated input. Combine with -j 8 for 8-way parallelism. We covered this in L16.


3. Atomic writes — never leave a half-finished file

If a process is killed (Ctrl-C, OOM, kernel panic) while writing a file, readers may see truncated content. For configs, manifests, anything important, write atomically: write to a temp file, fsync, then rename.

The basic pattern

TMP=$(mktemp /var/lib/myapp/data.XXXXXX)
trap 'rm -f "$TMP"' EXIT
generate_data > "$TMP"
mv -- "$TMP" /var/lib/myapp/data.json
trap - EXIT

mv on the same filesystem is atomic at the kernel level — readers either see the old version or the new one, never partial.

Why rename(2) is atomic

When you run mv tmp target on one filesystem, mv calls the rename(2) system call, and POSIX requires that call to be atomic: it either completely replaces target with tmp or does nothing at all. There is no observable moment where target is empty, half-written, or missing.

The mechanism is simple once you picture a directory as a table mapping names to inode numbers (an inode is the kernel’s handle for a file’s actual data and metadata). Your temp file already has a complete inode full of finished data. rename just rewrites one row of that table — the target name — to point at the temp file’s inode, and drops the temp name, in a single locked update. The data blocks never move; only a pointer flips. That is why the cost is independent of file size (renaming a 1-byte file and a 1-TB file take the same time), and why no reader can catch a partial state: at every instant the target name resolves to a whole inode, either the old one or the new one.

This is also the difference between mv tmp target and cp tmp target. cp opens the existing target inode and overwrites it in place, byte by byte — the exact half-written window you’re trying to avoid. mv (same-FS) leaves the old inode untouched and swaps to a brand-new one. Always build in a temp and mv; never cp over a live file.

Why same filesystem?

mv across filesystems is cp + unlink — non-atomic. If the destination is on /data (separate FS from /tmp), you must:

TMP=$(mktemp -p "$(dirname "$DEST")" data.XXXXXX)

-p DIR puts the temp file in DIR — same filesystem as DEST.

Adding fsync

mv is atomic from the kernel’s view, but the data may still be in page cache. To guarantee durability, sync first:

TMP=$(mktemp -p "$(dirname "$DEST")" .data.XXXXXX)
generate_data > "$TMP"
sync                      # flush ALL pending writes (heavyweight)
# Or, more targeted:
# python3 -c "import os; f=open('$TMP'); os.fsync(f.fileno())"
mv -- "$TMP" "$DEST"

For most use cases, the implicit kernel flushing is fine. Add explicit sync only when crashes are a real concern (e.g. database snapshots).

The subtle part — full durability needs two fsyncs. fsync on the temp file guarantees its data reaches stable storage, but the rename itself lives in the directory’s metadata, which is buffered separately. For a bulletproof “the new file is definitely there after a power cut” guarantee, you must also fsync the directory that contains it, after the mv:

mv -- "$TMP" "$DEST"
# Flush the directory entry too — durability, not just atomicity:
python3 -c 'import os,sys; d=os.open(sys.argv[1], os.O_RDONLY); os.fsync(d); os.close(d)' "$(dirname "$DEST")"

Skip the directory fsync and a crash immediately after mv can leave you with neither the old nor the new name. This is exactly what databases and package managers do internally. Plain scripts rarely need it — but when someone asks “is this write crash-safe?”, the honest answer is “only if I fsync the file and its directory.”

Reusable helper

atomic_write() {
  local target=$1
  local tmpdir; tmpdir=$(dirname "$target")
  local tmp; tmp=$(mktemp -p "$tmpdir" ".$(basename "$target").XXXXXX")
  trap 'rm -f "$tmp"' EXIT
  cat > "$tmp"                    # read stdin → temp file
  mv -- "$tmp" "$target"
  trap - EXIT
}

# Use:
generate_config | atomic_write /etc/myapp/config.yaml

cat > "$tmp" reads stdin to the temp. The function is generic.

Atomic directory replace

For atomically replacing a directory (e.g. blue/green static content):

NEW=/var/www/site.new
LIVE=/var/www/site
OLD=/var/www/site.old

# Build the new tree
rsync -aP /source/ "$NEW/"

# Atomic flip via rename — actually two-step on most filesystems
mv -- "$LIVE" "$OLD"          # not atomic in the strict sense
mv -- "$NEW" "$LIVE"          # but very fast — sub-millisecond gap

# Cleanup
rm -rf "$OLD"

For truly atomic dir-swap, use a symlink:

# Build the new dir at /var/www/sites/v2/
rsync -aP /source/ /var/www/sites/v2/

# Atomic symlink swap (mv replaces atomically)
ln -sfn /var/www/sites/v2 /var/www/site

The ln -sfn updates an existing symlink atomically (single rename syscall). This is how most blue/green static-content deployments work.


4. Parallel-safe directory operations

When fanning out file ops across cores, watch for races and contention.

Per-file work in parallel

# Compress every .log file with 8 cores
find /var/log -type f -name '*.log' -print0 \
  | xargs -0 -P 8 -n 1 gzip

This is safe — each gzip operates on its own file. No coordination needed.

Aggregating from parallel jobs

# Counting words across many files in parallel — DON'T just append
mkdir -p /tmp/results
find . -name '*.txt' -print0 | xargs -0 -P 8 -I {} \
  bash -c 'wc -w "$1" > "/tmp/results/$(basename "$1").wc"' _ {}

# Aggregate after
cat /tmp/results/*.wc | awk '{ s += $1 } END { print s }'

Each worker writes to its own file. After wait, aggregate. We saw this pattern in L16; here it’s specialised for file ops.

Walking very large trees efficiently

For directories with millions of files, naive find reads the whole tree at once. Use -maxdepth:

# Process top-level dirs in parallel; each find within is shallow
find /data -mindepth 1 -maxdepth 1 -type d -print0 \
  | xargs -0 -P 4 -I {} bash -c 'find "$1" -name "*.log" | wc -l' _ {}

This breaks the tree into independent subtrees, processes each in parallel.

rsync from many sources to one dest

rsync doesn’t natively parallelise. Workaround: rsync each top-level subdir in parallel.

ls -1 /source | parallel -j 4 'rsync -a /source/{}/ /dest/{}/'

Be careful with --delete — it deletes anything in the dest dir not present in the source dir, but each parallel rsync only sees its own subdir. So --delete is safe here as long as the dest layout mirrors the source.


5. Common patterns

Disk-usage one-liners

# Top 20 largest files in a tree
find /var -type f -printf '%s %p\n' | sort -rn | head -n 20

# Top 20 largest directories (by their direct content)
du -sh /var/* 2>/dev/null | sort -hr | head -n 20

# Total size of files matching a pattern
find . -name '*.log' -printf '%s\n' | awk '{s+=$1} END {print s}'

# Files older than 7 days, total size
find . -mtime +7 -type f -printf '%s\n' | awk '{s+=$1} END {print s/1024/1024 " MB"}'

Heads-up: find -printf is GNU-only. On BSD/macOS find there is no -printf at all — use find … -exec stat -f '%z %N' {} + (BSD stat) or install GNU findutils (gfind). The full portability map is in Going deeper.

Safely deleting many files

# DON'T — argv overflow risk
rm /var/cache/*.tmp

# DO — find + xargs handles arbitrary file counts
find /var/cache -name '*.tmp' -print0 | xargs -0 rm --

# Or with -delete (no fork, but no pre-list)
find /var/cache -name '*.tmp' -delete

Mirroring with hard links (zero-copy “branching”)

# Make a snapshot that shares storage with the original
cp -al /source /snapshot         # -a archive; -l hard-link instead of copy

# OR with rsync
rsync -a --link-dest=/source /source/ /snapshot/

Both create /snapshot/ where every file is a hard link to /source/. Both directories now point to the same blocks; modifying one (via overwrite, not in-place edit) breaks the link.

This is how container layer snapshots work conceptually.

Find files NOT matching a pattern

find . -type f -not -name '*.tmp'
find . -type f \! -name '*.tmp'      # ! escaped for shell

# Multiple patterns
find . -type f -not \( -name '*.tmp' -o -name '*.bak' \)

Atomic config reload without restart

# Pattern: write atomically, then signal the daemon
atomic_write /etc/myapp/config.yaml < new-config.yaml
systemctl reload myapp.service     # or kill -HUP $(cat myapp.pid)

The daemon re-reads on SIGHUP. Atomic-write means the daemon never reads a partial config.


6. Common pitfalls

cp losing perms / xattrs

cp file dest                         # may not preserve perms, ownership, ACLs
cp -a file dest                      # archive mode (preserves)
cp -p file dest                      # preserve mode/owner/timestamps only

Use -a (or cp --preserve=all) when fidelity matters.

rm -rf with empty variable

The classic disaster:

DIR=""
rm -rf "$DIR/cache"          # if DIR is empty: rm -rf "/cache" — DELETES /cache !

Always validate:

[[ -n "$DIR" ]] || die "DIR is empty"
rm -rf -- "$DIR/cache"

The -- ends option processing — protects against $DIR accidentally starting with a dash.

mv across filesystems

mv /var/data/big.tar /tmp/    # if /tmp is a separate FS, this is cp + unlink

Cross-FS mv can leave the source partially deleted if interrupted. For huge files, prefer cp -av && rm -- $src so you control timing.

find -exec with ; vs +

find . -name '*.log' -exec gzip {} \;     # forks gzip per file — slow
find . -name '*.log' -exec gzip {} +      # batched — fast

Always use + unless your command really only takes one file.

cp -r dir1/ dir2/ vs cp -r dir1 dir2/

cp -r dir1/ dir2/        # copies CONTENTS of dir1 into dir2
cp -r dir1  dir2/        # copies dir1 ITSELF into dir2 (creates dir2/dir1)

Same trailing-slash rule as rsync. Memorise.

Filesystem quirks

du vs ls -l size

ls -l file              # logical size (what you'd read)
du -h file              # disk usage (rounded to block; sparse files lower)

For sparse files (VM disk images, database files), du can be much smaller than ls -l. For atomic-write planning (where blocks matter), use du.


7. The lib/files.sh framework

# lib/files.sh — file-operation helpers

atomic_write() {
  local target=$1
  local tmp
  tmp=$(mktemp -p "$(dirname "$target")" ".$(basename "$target").XXXXXX")
  trap 'rm -f "$tmp"' EXIT
  cat > "$tmp"
  mv -- "$tmp" "$target"
  trap - EXIT
}

mirror_safely() {
  local src=$1 dst=$2
  [[ -d "$src" ]] || die "source not found: $src"
  rsync -aP --delete --exclude-from='.rsync-excludes' "$src/" "$dst/"
}

snapshot_with_link_dest() {
  local src=$1 dest_root=$2
  local today
  today=$(date -u +%Y-%m-%d)
  local target="$dest_root/$today"
  local prev
  prev=$(ls -1 "$dest_root" 2>/dev/null | sort -r | grep -E '^[0-9]{4}-[0-9]{2}-[0-9]{2}$' | head -1)
  local opts=( -aP --delete )
  [[ -n "$prev" && -d "$dest_root/$prev" ]] && opts+=( --link-dest="$dest_root/$prev" )
  rsync "${opts[@]}" "$src/" "$target/"
}

count_files() {
  find "$1" -type f -print0 | tr -cd '\0' | wc -c
}

total_size_mb() {
  find "$1" -type f -printf '%s\n' 2>/dev/null | awk '{s+=$1} END {print s/1024/1024}'
}

prune_older_than_days() {
  local dir=$1 days=$2
  find "$dir" -mindepth 1 -maxdepth 1 -type d -mtime "+$days" -print0 \
    | xargs -0r rm -rf
}

Use:

source "$(dirname "${BASH_SOURCE[0]}")/lib/files.sh"

generate_config | atomic_write /etc/myapp/config.yaml
snapshot_with_link_dest /var/www /backups
prune_older_than_days /backups 30

Note two portability seams in this framework worth knowing: count_files uses the neat tr -cd '\0' | wc -c trick (count the NUL delimiters -print0 emits) precisely because it avoids counting lines in filenames that contain newlines; and total_size_mb leans on GNU find -printf, so on BSD you’d swap it for find "$1" -type f -exec stat -f '%z' {} + | awk ….


8. Twelve idioms for daily use

# 1. rsync local copy (idiomatic)
rsync -aP /src/ /dst/

# 2. rsync remote
rsync -azP /src/ user@host:/dst/

# 3. rsync with delete + dry-run first
rsync -aPn --delete /src/ /dst/        # confirm
rsync -aP --delete /src/ /dst/         # apply

# 4. rsync incremental snapshots
rsync -aP --link-dest=$LAST_BACKUP /src/ /backups/$TODAY/

# 5. NUL-safe iteration
mapfile -d '' -t FILES < <(find . -type f -print0)
for f in "${FILES[@]}"; do …; done

# 6. find + parallel
find . -name '*.log' -print0 | xargs -0 -P 8 -n 1 gzip

# 7. Atomic write
TMP=$(mktemp -p "$(dirname $TARGET)" ".$(basename $TARGET).XXXXXX")
generate > "$TMP" && mv "$TMP" "$TARGET"

# 8. Atomic dir-replace via symlink
rsync -aP /src/ /dest/v2/ && ln -sfn /dest/v2 /dest/live

# 9. find -delete
find /tmp -mindepth 1 -mmin +60 -delete

# 10. Top 20 largest files
find /var -type f -printf '%s %p\n' | sort -rn | head -n 20

# 11. Hard-link snapshot (zero-copy clone)
cp -al /source /snapshot

# 12. Check before destructive op
[[ -n "$DIR" ]] || die "DIR empty"; rm -rf -- "$DIR/cache"

9. What you must internalise before lesson 19


Going deeper

This is the section for the reader who already runs rsync -aP and mktemp in their sleep and wants the internals, the portability landmines, and the production edge cases.

How rsync’s delta algorithm actually saves the day

The thing that makes rsync “smart cp” is the rsync algorithm: when copying A over an existing B, the receiver splits its copy of B into fixed-size blocks and computes, for each block, a fast rolling checksum plus a strong hash (MD5 in modern rsync). It sends those to the sender, which rolls a window byte-by-byte over A looking for blocks that already exist on the receiver. Matches are sent as “reuse block N”; only the non-matching runs of bytes are transmitted literally. Change one line in the middle of a 1 GB file and rsync ships a few KB, not a gigabyte.

Two consequences worth knowing:

--inplace, --partial, --partial-dir and the atomicity trade-off

By default rsync writes each file to a hidden temp (.filename.XXXXXX) in the destination directory and renames it into place when complete — i.e. rsync already does the atomic-write pattern for you, per file. That is why an interrupted rsync never leaves a truncated destination file (only, at worst, a leftover temp).

mktemp internals, and why $$-based temp names are a bug

mktemp exists to close a TOCTOU (time-of-check/time-of-use) race. The naïve TMP=/tmp/myapp.$$ (PID-based) is predictable: an attacker on a shared box can pre-create /tmp/myapp.<pid> as a symlink to a file you own, and your script then writes through it or chmods it — a classic privilege-escalation and data-clobber vector in world-writable /tmp. mktemp instead atomically creates a file with random characters (via O_CREAT|O_EXCL, which fails if the name already exists) and mode 0600, so the name is unpredictable and you’re guaranteed to be the creator.

Rules that follow:

Durability, rename, and the ext4 “zero-length files” saga

Atomicity (readers see old-or-new) and durability (survives a crash) are different guarantees. rename gives atomicity for free; durability needs fsync — and, as covered in §3, an fsync of the directory to persist the new name. Historically this bit Linux users hard: in 2009, ext4’s delayed allocation meant that applications doing the write-temp-then-rename dance without fsync could, after a crash, find the renamed file zero bytes long — the rename metadata had hit disk but the data blocks hadn’t. The kernel added a heuristic (auto-flush on rename-over-existing and truncate) to paper over the most common cases, but the correct, portable rule never changed: if you need the data to survive a crash, fsync the file before the rename and fsync the directory after it. For a deeper treatment of inodes, link counts and fsync, see Filesystem semantics: hard links, symlinks & fsync.

Hard links, link counts, and how --link-dest snapshots really behave

A hard link is a second directory entry pointing at the same inode — not a copy. cp -al and rsync --link-dest build snapshots where unchanged files are hard links to the previous snapshot, so N daily snapshots of mostly-static data cost roughly size(1 snapshot) + N×(changed files), not N × full. Check it directly:

stat -c '%h %i %s %n' file      # GNU: link-count, inode, size, name
stat -f '%l %i %z %N' file      # BSD/macOS equivalent

Two things that trip people up: (1) a file with link count 3 shows its full size in every snapshot’s ls -l, but du counts the blocks only once — so du -sh snapshot/ on a link-dest tree can read far smaller than the sum of ls sizes, which is correct. (2) Editors matter: a tool that truncates-and-rewrites in place mutates the shared inode and silently changes every snapshot; a tool that writes-temp-then-renames (the atomic pattern!) breaks the link and leaves old snapshots intact. Backup snapshots therefore rely on their writers using rename semantics — which is exactly why atomic writes and hard-link snapshots are the same lesson.

ARG_MAX: why xargs exists at all

rm /var/cache/*.tmp can fail with Argument list too long because the glob expands to a command line longer than the kernel’s ARG_MAX limit (environment + argv bytes for a single execve).

getconf ARG_MAX     # ~1048576 (1 MiB) on macOS; often 2 MiB on Linux

xargs reads the file list on stdin and batches it into as many execve calls as needed to stay under the limit — which is why find … -print0 | xargs -0 rm handles a million files where the glob dies. find … -exec cmd {} + does the same batching internally. The subtle payoff of -print0/-0 here is double: it’s both NUL-safe and the only way xargs can pack batches without mis-splitting a name that contains a space.

Copy-on-write reflinks: the modern zero-copy clone

On copy-on-write filesystems (Btrfs, XFS with reflink, ZFS, APFS) you can clone a file so the two names share data blocks until one is modified — a true instant clone, unlike a hard link the clone is fully independent for writes:

cp --reflink=auto src dst    # GNU coreutils on Btrfs/XFS: reflink if possible, else full copy
cp -c src dst                # macOS/APFS: clonefile()

--reflink=auto is the safe default in modern scripts on Linux: instant and space-free where the FS supports it, a normal copy where it doesn’t. It’s how snapshots, container layers, and “duplicate this 40 GB dataset for a test run” all become O(1).

GNU vs BSD: the portability map that actually bites

The build host for this course is Linux + GNU coreutils, but your scripts land on macOS laptops (BSD userland, bash 3.2), Alpine containers (BusyBox), and embedded boxes. These are the differences that break file-ops scripts in practice:

Feature GNU / Linux BSD / macOS Portable move
find -printf '%s %p' yes absent find … -exec stat … {} +, or install findutils
stat format stat -c '%s %n' stat -f '%z %N' branch on OS, or use find -printf (GNU) / wc -c
du -b (exact bytes) yes absent (has -h, -k) stat/find -printf, or du -k (KiB)
date -d 'yesterday' yes date -v-1d `date -d … 2>/dev/null
readlink -f yes modern macOS yes, old BSD no realpath (now on both)
sort -h (human sizes) yes modern macOS yes present on both current systems
cp --reflink yes (CoW FS) no (cp -c on APFS) cp --reflink=auto guards itself on Linux
mapfile/readarray -d bash 4.0 / 4.4 bash 3.2 lacks both while IFS= read -r -d '' loop
xargs -r (no-run-if-empty) yes absent on macOS guard input, or xargs + [ -s ] check
rsync version 3.x (Samba) often openrsync / 2.6.9-compat install rsync 3.x for --info=progress2, reliable --link-dest

Two of these deserve emphasis. First, xargs -r (--no-run-if-empty, used in the framework’s xargs -0r rm -rf) is a GNU extension — on macOS BSD xargs, empty input still runs the command once, so prune_older_than_days on an empty dir would run a bare rm -rf. Guard with an explicit emptiness check on BSD. Second, recent macOS ships openrsync (advertising “rsync version 2.6.9 compatible”), which lacks --info=progress2 and has thinner --link-dest support than Samba rsync 3.x — for serious backup scripts, install rsync 3.x (Homebrew) and don’t assume the system binary. For a systematic approach to detecting and guarding these, see POSIX portability vs bashisms.

Security and safety nuances


Common beginner mistakes

mv is always atomic, so writing to a temp anywhere and moving it is safe.” Only a same-filesystem mv is atomic. Across filesystems (/tmp/data, a bind mount, an NFS share, tmpfs → disk) mv silently becomes copy-then-unlink — non-atomic and interruptible, so a reader can catch a half-written destination. Right model: mktemp in the target’s own directory (mktemp "$(dirname "$DEST")/.tmp.XXXXXX") so the final mv never crosses a filesystem boundary. When unsure, compare device numbers: stat -f %d (BSD) / stat -c %d (GNU).

for f in $(find . -name '*.log') is fine as long as I quote "$f" in the loop.” The damage is already done before the loop body runs: command substitution splits find’s output on $IFS (spaces, tabs, newlines), so Q1 report.log becomes two words Q1 and report.log. Quoting "$f" inside the loop cannot un-split it. Right model: never iterate command substitution over filenames — use find … -print0 | while IFS= read -r -d '' or xargs -0, because NUL is the one byte a filename can’t contain.

rsync -a /src /dst/ and rsync -a /src/ /dst/ do the same thing.” The trailing slash on the source flips the meaning entirely. /src/ copies the contents of src into dst; /src (no slash) copies the directory src itself into dst, creating /dst/src/. Right model: trailing slash on source = “the contents of”; run it once with -n and read the paths before trusting it — this is the #1 rsync foot-gun.

--delete just tidies up stray files, it’s harmless.” --delete removes anything in the destination that isn’t in the source — so a swapped src/dst, a typo’d path, or an empty $SRC variable can wipe the destination (or, reversed, your source). Right model: --delete is destructive by design; always --dry-run (-n) first, prefer --delete-after, and never let --delete-excluded near data you meant to protect.

cp file dest copies the file faithfully.” Plain cp may drop permissions, ownership, ACLs, extended attributes and precise timestamps — you get the bytes, not the fidelity. Right model: use cp -a (archive) when metadata matters, cp -p to preserve just mode/owner/times, and remember mv preserves everything because (same-FS) it doesn’t copy at all.

fsync-ing the file makes my atomic write crash-proof.” fsync on the file persists its data, but the rename that publishes it lives in the directory’s metadata, which is flushed separately — a crash right after mv can leave you with neither name. Right model: for true durability, fsync the file before the rename and fsync the directory after it. (For pure atomicity — readers never see a torn file — the bare temp-then-rename is already enough; durability is the extra mile.)

rm /path/*.tmp is the obvious way to delete lots of files.” With enough matches the shell expands * past ARG_MAX and the command dies with Argument list too long — and if any filename starts with -, rm treats it as a flag. Right model: find /path -name '*.tmp' -print0 | xargs -0 rm -- (or find … -delete) streams the list past the argv limit, and -- stops option parsing.


Practice challenges

Set up a tiny sandbox, then work the challenges. Try each yourself before opening the solution.

mkdir -p /tmp/kv-lab && cd /tmp/kv-lab
mkdir -p src
printf 'hello\n'  > 'src/report final (v2).txt'   # note the spaces + parens
printf 'log one\n' > src/app.log
printf 'log two\n' > 'src/access log.log'          # a space in the name

1. (Beginner) List every .log file under src, one per line, safely — even the one with a space in its name. A for f in $(…) loop must not split access log.log into two.

<details> <summary>Solution</summary>

find src -name '*.log' -print0 | xargs -0 -n1 echo
# or, no xargs:
find src -name '*.log' -print0 | while IFS= read -r -d '' f; do printf '%s\n' "$f"; done

Why: -print0 separates names with NUL, the one byte a filename can’t contain, so xargs -0 / read -d '' treat access log.log as a single item. A $(find …) loop would split it on the space. </details>

2. (Beginner) Atomically write src/app.conf containing two lines, so a concurrent reader can never see a half-written file. Use mktemp + mv, and put the temp in the same directory as the target.

<details> <summary>Solution</summary>

DEST=src/app.conf
TMP=$(mktemp "$(dirname "$DEST")/.app.conf.XXXXXX")
trap 'rm -f "$TMP"' EXIT
printf 'mode = prod\nworkers = 4\n' > "$TMP"
mv -- "$TMP" "$DEST"
trap - EXIT
cat "$DEST"

Why: the temp is written fully first, then mv (same filesystem, since it’s the same dir) swaps the directory entry in one atomic rename(2) — readers see the old file or the whole new one, never partial. The trap cleans up the temp if anything fails before the mv. </details>

3. (Intermediate) Mirror src/ into dst/ so dst becomes an exact copy (extras removed) — but preview the deletions first. Never run --delete blind.

<details> <summary>Solution</summary>

mkdir -p dst && printf 'stale\n' > dst/old.txt   # something that should get deleted
rsync -aPn --delete src/ dst/                     # DRY RUN — read the "deleting old.txt" line
rsync -aP  --delete src/ dst/                     # apply

Why: -n (--dry-run) prints what would happen without touching anything, so you confirm --delete is only removing what you expect. The trailing slash on src/ copies its contents into dst. </details>

4. (Intermediate) Build a second snapshot of src that hard-links unchanged files to the first, so identical files share disk. Then prove the sharing with link counts.

<details> <summary>Solution</summary>

rsync -aP src/ snap1/                              # first full snapshot
rsync -aP --link-dest="$PWD/snap1" src/ snap2/     # unchanged files hard-linked to snap1
# Prove it — same inode, link count >= 2:
stat -f '%i %l %N' snap1/app.log snap2/app.log     # BSD/macOS
# GNU: stat -c '%i %h %n' snap1/app.log snap2/app.log

Why: --link-dest hard-links files identical to the reference tree instead of copying them, so snap2/app.log and snap1/app.log share one inode (link count ≥ 2) and cost zero extra data blocks. --link-dest needs an absolute path. (On macOS, use rsync 3.x for reliable --link-dest.) </details>

5. (Advanced) Write a portable atomic_write function that reads stdin, stages a temp in the target’s directory, cleans up on failure, and works in bash 3.2 (no mapfile, no GNU-only flags).

<details> <summary>Solution</summary>

atomic_write() {
  local target=$1 tmp
  tmp=$(mktemp "$(dirname "$target")/.$(basename "$target").XXXXXX") || return 1
  trap 'rm -f "$tmp"' RETURN            # clean up when the function returns
  cat > "$tmp"                          # slurp stdin into the temp
  mv -- "$tmp" "$target"                # atomic same-FS swap
}
printf 'k = v\n' | atomic_write src/generated.conf && cat src/generated.conf

Why: the temp lives in dirname target, guaranteeing a same-filesystem (atomic) mv; the template path form avoids GNU’s -p flag; trap … RETURN removes the temp if cat or mv fails. No bashism newer than 3.2 is used, so it runs on stock macOS too. </details>

6. (Advanced) Demonstrate the cross-filesystem trap and fix it. On most systems /tmp and your project dir can be different filesystems. Show why staging in /tmp and moving to src/ can be non-atomic, then fix it — and verify with device numbers.

<details> <summary>Solution</summary>

# Device numbers of the two candidate temp locations vs the target dir:
stat -f '%d %N' /tmp src            # BSD/macOS  (GNU: stat -c '%d %N')
# If the two device numbers DIFFER, then:
#   TMP=$(mktemp /tmp/x.XXXXXX); mv "$TMP" src/x   # mv = cp+unlink  → NOT atomic
# Fix: stage in the TARGET's directory so device numbers match:
DEST=src/x
TMP=$(mktemp "$(dirname "$DEST")/.x.XXXXXX")
stat -f '%d %N' "$TMP" "$DEST" 2>/dev/null || true   # same device now
printf 'safe\n' > "$TMP" && mv -- "$TMP" "$DEST"

Why: rename(2) is only atomic within one filesystem. If stat -f %d (stat -c %d on GNU) reports different device numbers for the temp and the target, mv degrades to a copy-then-unlink and a reader can catch the partial file. Staging in dirname "$DEST" forces one device, restoring atomicity. </details>


Glossary


What’s next

Lesson 19: Date & Time Arithmetic — ISO 8601, Time Zones, Locale Hazards & Reliable Cron-Time Math. Working with dates in shell is full of traps: date -d is GNU only; macOS BSD date uses different flags; the %N format isn’t portable; cron uses local time; UTC is mandatory in production. We cover the GNU/BSD difference comprehensively, the canonical ISO 8601 patterns, time-zone handling in scripts, computing yesterday/last-week/last-month dates, and the standard “cron-safe” date math. After L19 you’ll never have a “this script broke at DST” bug again — see Date & time arithmetic: ISO 8601 & time zones.

See you there.

shellbashrsyncfindfilesystematomic-writesparallelscalebackupsnapshots
Need this built for real?

Vinod is a Senior Cloud Architect (22+ yrs) — available for Azure / AWS / GCP architecture, landing zones, and migrations.

Work with me

Comments