Linux Basic — Directory Operations

4 minute read

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 with dotglob — 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

Updated: