Linux Basic — Directory Operations
Directory Operations
Creating, removing, traversing, and counting files in a directory looks trivial — and most beginner scripts get every one of those operations wrong. This page collects the safe, idiomatic patterns so you can stop reinventing them.
Idempotent Creation — mkdir -p
mkdir alone fails if the directory already exists or if any parent is missing:
mkdir /tmp/a/b/c
# mkdir: cannot create directory '/tmp/a/b/c': No such file or directory
mkdir /tmp/existing
mkdir /tmp/existing
# mkdir: cannot create directory '/tmp/existing': File exists
-p (“parents”) fixes both:
mkdir -p /tmp/a/b/c # creates a, a/b, a/b/c in one go
mkdir -p /tmp/existing # no-op if it already exists — exit 0
-p is idempotent: safe to call any number of times. Use it in every script that needs a directory to exist.
LOG_DIR=/var/log/myapp
mkdir -p -- "$LOG_DIR"
For permissions atomically:
install -d -m 0750 -o myapp -g myapp /var/log/myapp
install -d is the deploy-grade equivalent (see chmod & chown).
Empty-Directory Removal — rmdir
rmdir only removes empty directories — a useful safety net:
rmdir /tmp/empty # works
rmdir /tmp/has-stuff # rmdir: failed to remove 'has-stuff': Directory not empty
When you really want to clear empty leaves under a tree:
find /var/spool/app -depth -type d -empty -delete
-depth means descend first; -empty matches empty dirs; -delete removes them. Safe even if it processes nothing.
Safe Recursive Removal — Guarded rm -rf
rm -rf is irreversible — there’s no trash bin. Guards are non-negotiable:
# ❌ The classic disaster
rm -rf "$BASE/$DIR/" # if either var is empty: rm -rf /
# ✅ Multi-layer guard
[[ -n "$BASE" ]] || { echo "BASE unset" >&2; exit 1; }
[[ -n "$DIR" ]] || { echo "DIR unset" >&2; exit 1; }
target="$BASE/$DIR"
[[ -d "$target" ]] || { echo "$target not a dir" >&2; exit 1; }
# Whitelist of allowed parent paths
[[ "$target" =~ ^/(home|tmp|var/cache|opt/myapp)/ ]] \
|| { echo "$target outside allowed roots" >&2; exit 1; }
rm -rf -- "$target"
Five lines that prevent the worst day of your career.
A safer alternative for “delete contents but keep the directory”:
find "$target" -mindepth 1 -delete # never deletes the named dir itself
Iterating Files With Globs — Always Use nullglob
Globs (*.log, *.json) are how you say “every matching file”. The default behaviour when nothing matches is the booby trap:
shopt -u nullglob # default
for f in *.nonexistent; do
echo "Got: $f"
done
# Got: *.nonexistent ← literal pattern as if it were a real file!
The literal pattern then breaks every command you pass it to. Fix it once at the top of the script:
shopt -s nullglob # empty matches expand to nothing
for f in *.nonexistent; do
echo "Got: $f"
done
# (loop body runs zero times — correct)
Companion options:
| Option | Effect |
|---|---|
shopt -s nullglob |
empty matches expand to nothing |
shopt -s failglob |
empty matches error (good for required inputs) |
shopt -s dotglob |
globs include hidden files (those starting with .) |
shopt -s nocaseglob |
case-insensitive matching |
shopt -s globstar |
** recurses into subdirs |
shopt -s nullglob globstar dotglob
# All Markdown files anywhere under docs/, including hidden ones
for f in docs/**/*.md; do
echo "$f"
done
Counting Files Correctly
The most-Googled and most-wrong recipe:
# ❌ Breaks on files with newlines in the name (yes, that's legal)
ls *.log | wc -l
Safer alternatives:
# ✅ Use an array
shopt -s nullglob
files=( *.log )
echo "${#files[@]}" # 3
# ✅ Use find with -print0 + null-delimited counter
count=$(find . -maxdepth 1 -type f -name '*.log' -print0 | grep -zc .)
echo "$count"
# ✅ Count regular files only, recursively
find /var/log -type f | wc -l # OK for normal filenames; not for newlines
find /var/log -type f -printf x | wc -c # one 'x' per file → safe count
The array form is the cleanest in pure Bash — you also get the list for later use.
Hidden Files
Files starting with . are hidden by convention. The shell skips them in globs by default:
ls -la
# . .. .config visible.txt
for f in *; do echo "$f"; done
# visible.txt ← .config not included
shopt -s dotglob
for f in *; do echo "$f"; done
# .config
# visible.txt
Two notes:
*never matches.or..even withdotglob— those are special- If you only want hidden files:
for f in .[!.]*; do …; done(excludes.and any name starting with..)
Cleaning Empty Directories After a Job
A common pattern at the end of a batch job:
# Delete empty subdirs under the output area
find /var/output -depth -type d -empty -delete
# Delete files older than 30 days, then any newly-empty dirs
find /var/output -type f -mtime +30 -delete
find /var/output -depth -type d -empty -delete
-depth makes find process children before parents — required so the parent becomes empty before -empty checks it.
Move a Directory Atomically
Moving within the same filesystem is a rename — atomic. Across filesystems it’s a copy + delete — not atomic and slow.
mv /var/output/run-tmp /var/output/run-2026-06-17 # same FS — fast atomic rename
mv /var/output/run-tmp /backup/run-2026-06-17 # different FS — copies bytes
Check before moving large trees across filesystems:
src_dev=$(stat -c %d /var/output/run-tmp)
dst_dev=$(stat -c %d /var/output/)
[[ "$src_dev" == "$dst_dev" ]] && echo "same FS, will be atomic"
Common Mistakes
| ❌ Wrong | ✅ Right | Issue |
|---|---|---|
mkdir /a/b/c |
mkdir -p /a/b/c |
fails if parent missing |
rm -rf $dir/* (no guard) |
guard $dir first, prefer find -mindepth 1 -delete |
unset var → root wipe |
ls *.log \| wc -l |
array + ${#arr[@]}, or find … -printf x \| wc -c |
breaks on newline-in-filename |
for f in *.log; do … without nullglob |
shopt -s nullglob first |
literal *.log if no matches |
cp -r src/ dest/ to “move” |
mv (within FS) |
copy + delete = not atomic |
Next → Practice Exercises