Zsh Shell Tutorial #14: Master File Operations
Video: Zsh Shell Tutorial #14: Master File Operations by Taught by Celeste AI - AI Coding Coach
Zsh Lesson 14: File I/O — Read, Write, Tests, Heredocs
Read with
$(<file)(whole file) orwhile IFS= read -r line; do ... done < file(line by line). Write with> file(overwrite) or>> file(append).teefor "write + display." Heredocs<<EOF ... EOFfor 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.