Back to Blog

Zsh Shell Tutorial #14: Master File Operations

Sandy LaneSandy Lane

Video: Zsh Shell Tutorial #14: Master File Operations by Taught by Celeste AI - AI Coding Coach

Take the quiz on the full lesson page
Test what you've read · interactive walkthrough

Zsh Lesson 14: File I/O — Read, Write, Tests, Heredocs

Read with $(<file) (whole file) or while IFS= read -r line; do ... done < file (line by line). Write with > file (overwrite) or >> file (append). tee for "write + display." Heredocs <<EOF ... EOF for multi-line. File tests -e -f -d -r -w -x -s.

Almost every script reads or writes files. The patterns are short — once you know them.

Read entire file

data=$(<fruits.txt)
echo "$data"

$(<file) is Zsh/Bash shorthand for "contents of file as a string." Faster than $(cat file) — no fork.

For huge files: don't load the whole thing — iterate line by line.

Read line by line

while IFS= read -r line; do
  echo "Line: $line"
done < fruits.txt

The canonical pattern. Already covered in lesson 10:

  • IFS= — preserve whitespace.
  • -r — don't interpret backslashes.
  • < file — pipe file as stdin.

For numbered output:

count=0
while IFS= read -r line; do
  ((count++))
  echo "$count: $line"
done < fruits.txt

Counting lines, words, characters

lines=$(wc -l < fruits.txt)
words=$(wc -w < fruits.txt)
chars=$(wc -c < fruits.txt)

echo "Lines: $lines, Words: $words, Chars: $chars"

wc < file (with redirection) gives just the number. wc file (with filename arg) prints the filename too. Subtle but matters.

Searching: grep

grep "ERROR" log.txt              # lines containing ERROR
grep -i "warning" log.txt         # case-insensitive
grep -n "fail" log.txt            # with line numbers
grep -c "ok" log.txt              # count matches
grep -v "DEBUG" log.txt           # invert: lines NOT containing DEBUG
grep -E "(error|fail)" log.txt    # extended regex with alternation
grep -r "TODO" .                  # recursive in current dir

Lesson 17 covers grep in depth.

Write: > and >>

echo "Hello World" > output.txt           # truncate + write
echo "Second line" >> output.txt           # append

cat output.txt
# Hello World
# Second line

> overwrites; >> appends. For multiple lines, repeat:

echo "line one" > log.txt
echo "line two" >> log.txt
echo "line three" >> log.txt

Or use printf with \n:

printf "line one\nline two\nline three\n" > log.txt

Heredoc: multi-line literal

cat <<EOF > config.txt
host=localhost
port=8080
debug=true
EOF

Everything between <<EOF and the line containing only EOF is sent as input. Useful for:

  • Config files.
  • Multi-line messages.
  • SQL queries.
  • Embedded scripts.

Variables expand inside heredocs:

USER="alice"
cat <<EOF
Hello, $USER!
Today is $(date +%A).
EOF

To prevent expansion, quote the marker:

cat <<'EOF'
Literal $USER, no expansion.
EOF

The <<- form strips leading tabs (only tabs, not spaces) — useful for indenting heredocs in if-blocks:

if [[ -n "$verbose" ]]; then
  cat <<-EOF
    Hello
    World
    EOF
fi

Here-string: <<<

read -r line <<< "Hello World"
echo "$line"

<<< passes the string as stdin. Cleaner than echo "x" | command.

grep "world" <<< "Hello world"

tee: write and display

echo "Important entry" | tee log.txt
# Important entry      <- printed
# (also written to log.txt)

tee writes its stdin to both the named file and stdout. Use for logging while still seeing output:

make 2>&1 | tee build.log
# See output AND save it

Append mode:

echo "another" | tee -a log.txt

File tests (recap)

[[ -e file ]]     # exists (any type)
[[ -f file ]]     # regular file
[[ -d file ]]     # directory
[[ -L file ]]     # symlink
[[ -r file ]]     # readable
[[ -w file ]]     # writable
[[ -x file ]]     # executable
[[ -s file ]]     # non-empty
[[ -z file ]]     # zero size? — actually this tests string. For empty file: ! [[ -s file ]]
[[ "$f1" -nt "$f2" ]]   # newer than
[[ "$f1" -ot "$f2" ]]   # older than

Standard "is the file ready?" check:

if [[ -f "$config" && -r "$config" ]]; then
  source "$config"
else
  echo "Missing or unreadable: $config" >&2
  exit 1
fi

Reading until a specific marker

while IFS= read -r line; do
  [[ "$line" == "END" ]] && break
  echo "$line"
done

Useful for reading interactive input until a sentinel.

Reading specific fields

# Read CSV: name,age,role
while IFS=',' read -r name age role; do
  echo "$name is $age, role $role"
done < people.csv

IFS=',' splits each line on ,. Read into multiple variables. Cleaner than splitting in shell.

File metadata

stat -f%z myfile.txt    # size in bytes (macOS)
stat -c%s myfile.txt    # size in bytes (Linux)
stat -f%m myfile.txt    # modification time (macOS, Unix epoch)

# Or use `wc`:
wc -c myfile.txt        # byte count

# `du` for size on disk:
du -sh myfile.txt       # human-readable

stat syntax differs between macOS (BSD) and Linux (GNU). For portable scripts, prefer wc for size and find -printf for metadata.

Working with directories

mkdir -p project/{src,tests,docs}    # nested + brace expansion
cd project

# Iterate files recursively
for f in **/*.sh; do
  echo "$f"
done

# Find all files
find . -type f
find . -type f -name "*.sh"
find . -type d                       # directories

find is more powerful than globs (lesson 21).

A real backup script

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

SRC="${1:-$HOME/Documents}"
DEST="/tmp/backup-$(date +%Y%m%d-%H%M%S).tar.gz"

if [[ ! -d "$SRC" ]]; then
  echo "Error: $SRC not a directory" >&2
  exit 1
fi

# Create tar archive
tar -czf "$DEST" -C "$(dirname "$SRC")" "$(basename "$SRC")"

# Verify
if [[ -s "$DEST" ]]; then
  size=$(du -h "$DEST" | cut -f1)
  echo "Backed up to: $DEST ($size)"
else
  echo "Error: backup is empty" >&2
  exit 1
fi

Validates input, creates a timestamped archive, verifies non-empty result.

Process substitution

diff <(grep ERROR log1.txt) <(grep ERROR log2.txt)

<(cmd) makes the command's output look like a file. Useful for tools that take file arguments but you have command output.

Or for a while loop without subshell issues:

while IFS= read -r line; do
  ((count++))
done < <(grep ERROR log.txt)
echo "$count"

Compare to grep ERROR log.txt | while read line; do ((count++)); done; echo $count — that runs the loop in a subshell, and count is lost.

Common stumbles

> file clobbers without warning. Set setopt noclobber to make > error if file exists. Override with >|.

echo with backslashes. echo "a\nb" may or may not interpret \n. Use printf "a\nb\n" for predictability.

Reading a file via pipe. cat file | while read line; do ...; done — the loop is in a subshell. Use done < file instead.

while read swallowing the last line. If the file has no trailing newline, read returns 1 on the last line after assigning it. Combined with set -e, this exits.

while IFS= read -r line || [[ -n "$line" ]]; do
  process "$line"
done < file

The || [[ -n "$line" ]] handles the no-newline case.

Heredoc indentation. <<EOF doesn't strip indentation. Use <<-EOF to strip leading tabs (not spaces).

grep returning exit code 1 with set -e. When grep finds nothing, it returns 1 — exits the script. Wrap: grep ... || true.

Writing output then reading it. Make sure to > file then read; otherwise you may read while writing. For atomic writes, > tmp && mv tmp file.

What's next

Lesson 15: process management. ps, kill, jobs, signals.

Recap

$(<file) reads whole file. while IFS= read -r line; do ... done < file for line-by-line. > file overwrites, >> file appends. Heredoc <<EOF ... EOF for multi-line; <<'EOF' to disable expansion. tee writes + displays. <<< here-string. <(cmd) process substitution. File tests -e -f -d -r -w -x -s. Watch set -e with grep/read — both can return non-zero on normal cases.

Next lesson: process management.

Ready? Take the quiz on the full lesson page →
Test what you've learned. Watch the lesson and try the interactive quiz on the same page.