paulserban.eu

Writing Edition

Paul Serban

Writing, snippets & book notes

April 14, 2019

Navigating Through Unix: A Journey from Creating to Deleting Files

A practical guide to touch, mkdir, cp, mv, and rm - and the safety habits that keep them from becoming liabilities

Introduction

File management commands are among the first things anyone learns on Unix, and also among the most dangerous to use carelessly for an entire career. touch, mkdir, cp, mv, and rm look simple enough to master in an afternoon, and functionally they are-but the gap between "knows the commands" and "uses them safely under pressure" is where a surprising number of production incidents live. A single unquoted variable or a misplaced wildcard turns an ordinary cleanup command into a data-loss event, which is why this corner of the command line rewards deliberate practice far more than its apparent simplicity suggests.

This guide walks through the full lifecycle these commands represent: creating files and directories with intention, copying and moving data while preserving what matters about it (permissions, timestamps, ownership), and deleting with enough caution that rm remains a tool rather than a liability. The goal isn't just command syntax-plenty of references cover that-but the reasoning behind flag choices and the habits that separate routine file operations from the ones that end up in a postmortem.

Creating Files and Directories with Intention

The touch command is usually introduced as "create an empty file", but that undersells what it actually does. touch updates a file's access and modification timestamps, creating the file only as a side effect if it doesn't already exist. This distinction matters in build systems and Makefiles, where touch is frequently used to force a target to appear newer than its dependencies without changing its content-a common technique for forcing a rebuild step or working around stale timestamp comparisons in incremental build tooling.

touch file.txt                      # create if missing, or update timestamps if it exists
touch file{1..3}.txt                # brace expansion creates file1.txt, file2.txt, file3.txt
touch -d "1 day ago" snapshot.marker  # set a timestamp relative to now, useful in test fixtures
touch -r reference.txt target.txt    # copy reference.txt's timestamp onto target.txt

The -a and -m flags let you update only the access time or only the modification time respectively, which is occasionally useful when testing time-based logic (cache invalidation, log rotation) without altering file content. -c prevents touch from creating a file that doesn't exist, which is a safer default in scripts that are meant to update existing files rather than accidentally create placeholder files from a typoed path.

mkdir -p solves a problem that's easy to underappreciate until you've hit it: creating a nested directory structure in one command rather than stepping through each level manually. mkdir -p dir1/dir2/dir3 creates all three levels if they don't exist, and critically, does not error if some or all of them already exist-making it idempotent and therefore safe to call repeatedly in setup scripts, which is exactly the property you want in provisioning or CI logic that may run multiple times against the same environment.

# Idempotent directory scaffolding for a new service
mkdir -p "services/${SERVICE_NAME}"/{src,tests,config,scripts}

Copying and Moving: Preserving Meaning, Not Just Bytes

cp and mv look like siblings-both relocate data-but they solve different problems and carry different guarantees worth understanding precisely. cp duplicates data, and by default, a copy does not preserve the original's permissions, ownership, or timestamps; the new file gets fresh metadata reflecting the moment it was created and the umask of the user running the command. This default is often wrong for operational tasks like backups or deployments, where preserving exact file attributes matters. The -p flag addresses this directly, preserving mode, ownership, and timestamps, while -a (archive mode) goes further, preserving attributes recursively and handling symbolic links and special files correctly-making it the standard choice for full directory backups rather than -r alone.

cp -a /etc/nginx /backups/nginx-$(date +%Y%m%d)   # full fidelity backup: permissions, ownership, timestamps
cp -i deploy.sh deploy.sh.bak                      # prompt before overwriting an existing backup
cp -u source/*.conf /etc/app/conf.d/               # only copy files newer than the destination

mv, by contrast, doesn't duplicate data at all when source and destination are on the same filesystem-it simply updates the directory entry, making the operation close to instantaneous regardless of file size, since no data blocks are actually read or rewritten. This is worth knowing because it explains an otherwise confusing behavior: moving a large file across filesystems (say, from one mounted volume to another) is noticeably slower than moving it within the same filesystem, because in that case mv has to fall back to a full copy followed by deletion of the original, since directory entries can't span separate filesystems. mv preserves a file's original permissions, ownership, and timestamps automatically in both cases, since it's either just relabeling the same inode or performing an underlying copy that retains those attributes-there's no equivalent -p or -a flag because preservation isn't optional the way it is with cp.

mv src.txt dest.txt              # same-filesystem move: instant, metadata preserved
mv -i staging.yaml config.yaml   # prompt before silently overwriting an existing file
mv -n *.log archive/             # skip any file that would overwrite an existing one in the destination

Both commands support -i (interactive, prompting before overwrite) and -n (no-clobber, silently skipping instead of overwriting), and in scripted or automated contexts, -n is generally the safer default since a prompt that nobody can answer in a non-interactive shell will simply hang or fail, whereas -n fails safe by doing nothing to the conflicting file.

Deleting Files: Where Caution Becomes Mandatory

rm is the one command in this lifecycle where a moment of carelessness has no built-in safety net. Unlike most desktop operating systems, there is no trash bin or recycling step by default-rm unlinks a file immediately, and once every hard link to that file's inode is removed, its data blocks are released and the content is, for practical purposes, gone. This isn't a design flaw; it reflects the same directory-entry-versus-inode model discussed in file linking-rm is simply removing a directory entry, and the underlying data persists only as long as some other entry still references it.

The flags that make rm dangerous are the same ones that make it useful in automation: -r for recursive deletion through a directory tree, and -f to force deletion without prompting, even for files that would otherwise ask for confirmation (write-protected files, for instance). Combined as -rf, these two flags remove an entire directory tree with no confirmation at any step, which is exactly the behavior you want in a controlled build-cleanup script and exactly the behavior you don't want when a variable expands unexpectedly to something broader than intended.

rm -i important-config.yaml     # explicit confirmation prompt before deleting
rm -d empty_directory           # remove a directory only if it's empty (safer than -r)
rm -v *.tmp                     # verbose: print each file as it's removed, useful for audit trails
rm -rf ./build                  # remove a known, specific build directory recursively

The failure mode worth internalizing is not the command itself but the conditions under which it runs unexamined. A script that computes a path from user input or an environment variable and then passes that path directly to rm -rf is only as safe as the guarantee that the variable is never empty, never unset, and never equal to / or another unintended location. This is precisely the scenario behind a well-known and frequently repeated category of production incidents, and it's entirely preventable with a small amount of defensive validation before the destructive command ever executes.

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

TARGET_DIR="${1:?Usage: $0 <directory-to-clean>}"

# Guard against empty, root, or suspiciously short paths before deleting
if [[ -z "$TARGET_DIR" || "$TARGET_DIR" == "/" || ${#TARGET_DIR} -lt 4 ]]; then
    echo "Refusing to delete suspicious path: '$TARGET_DIR'" >&2
    exit 1
fi

if [[ -d "$TARGET_DIR" ]]; then
    rm -rf -- "$TARGET_DIR"
    echo "Removed $TARGET_DIR"
else
    echo "Directory not found: $TARGET_DIR" >&2
    exit 1
fi

The -- before the path in this example is a small but meaningful detail: it signals to rm that everything following is a positional argument, not a flag, which prevents a path accidentally starting with a hyphen from being misinterpreted as an option. This is a minor addition with an outsized payoff in scripts that build paths dynamically from variables.

Common Pitfalls in Everyday File Operations

A recurring mistake with cp and mv involves wildcard expansion interacting badly with assumptions about directory contents. cp *.log archive/ behaves correctly when at least one .log file exists in the current directory, but if none do, most shells pass the literal, unexpanded string *.log to cp, which then fails with a "no such file" error rather than silently doing nothing-a subtlety that trips up scripts expecting graceful handling of an empty match set. Testing explicitly for matches before acting on them, or enabling shell options like Bash's nullglob, avoids this ambiguity.

A second common pitfall involves overwriting through mv or cp without realizing a destination file already exists and matters. Neither command warns by default; cp src.txt dest.txt silently replaces dest.txt if it exists, with no confirmation and no backup of what was overwritten. Making -i or -n a personal default-via a shell alias, for interactive use-removes an entire category of "I didn't realize that file was already there" mistakes, though it's worth remembering that aliases don't carry into scripts or into other users' shells, so scripts should pass these flags explicitly rather than relying on aliased defaults that won't be present in a different execution context.

Best Practices for Safe File Lifecycle Management

Treat destructive and semi-destructive operations with the same rigor you'd apply to a database migration: dry-run first, validate inputs, and avoid broad wildcards in anything that writes or deletes. Before running an rm -rf or a bulk mv driven by a wildcard, run the equivalent ls or find command first to see exactly what will be affected-this costs seconds and catches mistakes before they're irreversible. In scripts specifically, validate that path variables are non-empty and don't resolve to unintended locations like the filesystem root, and use set -euo pipefail so that an earlier failure (such as a failed cd) doesn't silently let a later destructive command run in the wrong directory.

Prefer cp -a over cp -r for anything resembling a backup or migration, since preserving permissions, ownership, and timestamps is almost always the intended behavior for that use case, and the default cp behavior of resetting metadata is easy to overlook until a restored backup has the wrong file permissions. For deletions specifically, favor targeted, explicit paths over broad wildcards wherever possible (rm -rf ./build/output rather than rm -rf ./build/*), since a typo or unexpected working directory is far less consequential against a narrow, explicit target than against a wildcard that could expand to match more than intended.

Key Takeaways

Conclusion

The commands covered here-touch, mkdir, cp, mv, rm-form a complete lifecycle for file and directory management, and their apparent simplicity is exactly why they deserve deliberate, careful practice rather than casual familiarity. Creating and organizing data safely is table stakes; the real skill is handling copying, moving, and especially deletion with an awareness of what can go wrong and the habits that prevent it.

None of the safety practices described here are exotic-validating inputs, previewing matches before acting, preferring explicit paths over wildcards-but they're the difference between a command-line fluency that holds up under pressure and one that works fine until the one time it doesn't. Treating these fundamentals with real rigor is what makes everything built on top of them, from deployment scripts to backup pipelines, trustworthy rather than merely convenient.

References