Skip to content

06 · Working with Files & Directories

🎥 Video walkthrough

Creating, copying, moving, removing

mkdir logs                  # create a directory
mkdir -p data/raw/2026       # create nested directories, no error if they exist

touch notes.txt               # create an empty file (or update its timestamp)

cp notes.txt notes.bak        # copy a file
cp -r logs logs_backup        # copy a directory recursively

mv notes.bak archive/notes.bak   # move (or rename) a file
mv old_name.txt new_name.txt     # mv is also how you rename

rm notes.txt                  # remove a file
rm -r logs_backup             # remove a directory recursively
rm -rf temp_dir                # remove without confirmation, ignore missing — use carefully

rm -rf is permanent and has no undo — always double-check the path (and consider set -e from Module 9, plus quoting variables) before using it in a script.

File test operators

These pair with if [[ ... ]] from Module 3 to make scripts that check before acting:

path="config.yml"

if [[ -e "$path" ]]; then echo "exists"; fi
if [[ -f "$path" ]]; then echo "is a regular file"; fi
if [[ -d "$path" ]]; then echo "is a directory"; fi
if [[ -L "$path" ]]; then echo "is a symlink"; fi
if [[ -r "$path" ]]; then echo "readable"; fi
if [[ -w "$path" ]]; then echo "writable"; fi
if [[ -x "$path" ]]; then echo "executable"; fi
if [[ -s "$path" ]]; then echo "non-empty"; fi
Operator True when...
-e path exists (any type)
-f path exists and is a regular file
-d path exists and is a directory
-L path exists and is a symbolic link
-r / -w / -x readable / writable / executable
-s exists and has size greater than zero
-nt / -ot file is newer/older than another (e.g. a -nt b)

Listing and inspecting

ls -la                 # long listing, including hidden files
ls -lh                 # human-readable sizes (K, M, G)
find . -name "*.log"    # find files by name pattern, recursively
find . -type d -name "tmp*"    # find directories matching a pattern
du -sh some_dir         # total size of a directory, human-readable
stat notes.txt           # detailed metadata: size, permissions, timestamps

Permissions

chmod +x deploy.sh        # add execute permission for everyone
chmod 755 deploy.sh        # rwxr-xr-x explicitly (owner rwx, group/other rx)
chmod 644 config.yml       # rw-r--r-- — typical for a non-executable data file
chmod u+w,go-w shared.txt  # owner gets write, group/other lose write

chown alice notes.txt       # change file owner (may need sudo)

Permission digits are read=4, write=2, execute=1, added together per category (owner, group, other) — 755 = owner 7 (4+2+1=rwx), group 5 (4+1=r-x), other 5 (r-x).

Building paths safely in scripts

base_dir="/var/log/myapp"
today=$(date +%F)                 # 2026-07-18
log_file="$base_dir/$today.log"

mkdir -p "$base_dir"                # ensure the directory exists first
touch "$log_file"
echo "writing to $log_file"

Always quote path variables ("$base_dir") — an unquoted path containing a space will be split into multiple arguments by commands like mkdir or rm.

Looping over files safely

# Good: handles filenames with spaces correctly
for file in *.csv; do
    [[ -e "$file" ]] || continue   # skip if the glob matched nothing
    echo "processing $file"
done
# For recursive processing, prefer find -print0 piped to a null-delimited read
find . -name "*.log" -print0 | while IFS= read -r -d '' file; do
    echo "log: $file"
done

-print0 / -d '' use the NUL byte as a separator instead of newlines — the only fully safe way to handle filenames that might contain spaces, or even newlines.

How It Actually Works

Every filesystem operation here maps to a specific syscall: mkdir calls mkdir(2), touch calls open(2) with O_CREAT (or utimensat(2) if the file already exists), cp is open+read+write in a loop (or a copy-accelerating call like copy_file_range on Linux), and mv tries rename(2) first — which is why moving a file within the same filesystem is nearly instant (it just repoints a directory entry to the same inode) while moving across filesystems silently falls back to a full copy-then-delete, because rename(2) can't relink inodes across separate filesystem devices.

A directory entry isn't the file — it's a name-to-inode mapping. rm doesn't erase data; it calls unlink(2), which removes that name-to-inode link and decrements the inode's link count. The underlying blocks are only freed once the link count and the count of processes with the file still open both hit zero — which is exactly why a program can keep writing to a log file after you rm it: the inode survives until every open file descriptor referencing it is closed.

Globbing (*.txt) is expanded entirely by the shell before the command ever runs — rm *.txt never passes the string *.txt to rm; bash reads the current directory via readdir(2)-style enumeration, matches names against the pattern, and hands rm a fully expanded argument list. If nothing matches, the literal pattern is passed through unchanged unless nullglob or failglob is set — a classic source of "no such file" surprises.

🔀 See this in another language

Exercise

Write organize.sh that creates a directory called sorted/, then loops over every file in the current directory and moves each one into a subdirectory of sorted/ named after its extension (e.g. report.pdf goes to sorted/pdf/report.pdf). Skip directories and handle files with no extension by putting them in sorted/other/.