Linux Basic — Writing Files in Bash

4 minute read

Writing Files

Writing a file looks trivial — echo "hi" > file — and that is exactly why most production bugs around file writing happen. Partial writes during crashes, lost newlines from echo, broken redirection of stderr, and accidental overwrites are all preventable with a few simple habits.


The Two Redirection Operators

echo "first"  >  out.txt    # OVERWRITE — truncates and writes
echo "second" >> out.txt    # APPEND   — adds to end (creates if missing)
cat out.txt
# first
# second
  • > truncates the file first (any existing content is lost)
  • >> appends (always safe for adding)
  • Both create the file if it does not exist

Prefer printf Over echo in Scripts

echo is portable in name only — its behaviour around -e, -n, and backslash escapes varies between shells, builtins, and /bin/echo. printf is consistent everywhere.

# ❌ echo is unpredictable across systems
echo -e "first\nsecond"
# On Bash:   first
#            second
# On dash:   -e first\nsecond     ← prints the -e!

# ✅ printf is reliable
printf '%s\n' "first" "second"
# first
# second

printf cheat sheet:

Format Effect
%s string
%d integer
%f float (use %.2f for two decimals)
%x hex
%q shell-quoted (escape for re-execution)
\n newline
\t tab
%b string with backslash interpretation
printf '%-10s %5d\n' "apples" 42
printf '%-10s %5d\n' "bananas" 7
# apples         42
# bananas         7

Atomic Writes — Never Leave a Partial File

If a script crashes mid-write, you can be left with a half-written file that downstream tools then process as if it were complete. The cure is atomic write: write to a temporary file first, then rename it. On a POSIX filesystem, mv within the same directory is atomic.

# ❌ Not atomic — if the second line never runs, file is half-written
generate_report > /var/lib/app/report.json

# ✅ Atomic
tmp=$(mktemp /var/lib/app/.report.XXXXXX)
generate_report > "$tmp" && mv "$tmp" /var/lib/app/report.json

Why this works:

  • Any reader sees either the old complete file or the new complete file
  • A crash during generate_report leaves only $tmp (which you can clean up)
  • No reader ever sees a partially-written file

Tip: keep the tempfile in the same directory as the target — cross-directory mv can fall back to copy+delete (no longer atomic).


Overwrite Protection — noclobber

set -o noclobber                 # or: set -C

echo "x" > existing.txt          # bash: existing.txt: cannot overwrite existing file
echo "x" >| existing.txt         # force — overrides noclobber for this one redirection
echo "x" >> existing.txt         # >> is unaffected — safe to append

noclobber is a great default at the top of a script: it stops you from accidentally overwriting an existing file with >. Use >| when you genuinely want to overwrite.


Redirecting stdout, stderr, and Both

Every process has three open streams: 0 (stdin), 1 (stdout), 2 (stderr).

# Run a command that writes to both streams
ls / missing 2>/dev/null
# /bin /etc ...    (only the success output — errors went to /dev/null)

ls / missing 1>/dev/null
# ls: cannot access 'missing': No such file or directory
# (only the errors — success went to /dev/null)

Both streams to one file

# Modern Bash shorthand
build.sh &> build.log

# Portable form — write stdout, then merge stderr into it
build.sh > build.log 2>&1

# WRONG — order matters: this sends stderr to the screen
build.sh 2>&1 > build.log

The first form merges streams at the destination. The second sends stderr to wherever stdout was pointing at the time — which was still the terminal — then redirects stdout.

Separate stdout and stderr

build.sh > build.out 2> build.err

Swap them (advanced)

# Make stdout into stderr and stderr into stdout
cmd 3>&1 1>&2 2>&3

Writing Multiple Lines

# A heredoc is the cleanest way to write a multi-line file
cat > /etc/myapp.conf <<'EOF'
listen = 0.0.0.0:8080
workers = 4
debug = false
EOF

# Quoted EOF (single quotes) means: do NOT expand variables inside
# Without quotes, $vars and `cmds` ARE expanded
NAME="webapp"
cat > /etc/myapp.conf <<EOF
name = $NAME
EOF
cat /etc/myapp.conf
# name = webapp

Heredocs are covered in detail in Advanced Scripting → Here-Documents.


Common Patterns

Write a JSON file from values

name="webapp"
version="1.4.2"

cat > app.json <<EOF
{
  "name":    "$name",
  "version": "$version"
}
EOF

Write only if the content differs

new=$(generate_config)
if ! diff -q <(printf '%s' "$new") /etc/app.conf >/dev/null 2>&1; then
    printf '%s' "$new" > /etc/app.conf
    systemctl reload app
fi

Truncate without deleting

: > big.log         # truncate to zero bytes (the file stays — same inode)
> big.log           # equivalent

Useful when other processes have the file open: they keep their FD; only the contents are wiped.


Common Mistakes

❌ Wrong ✅ Right Issue
echo -e "\t" printf '\t' echo -e not portable
cmd > f 2>&1 \| less cmd 2>&1 \| less first form sends stderr to f, then to less
cmd 2>&1 > f cmd > f 2>&1 order matters for redirection
generate > /var/data/x.json tempfile + atomic mv partial writes on crash
echo "$big" > f in a hot loop build in a variable then write once thousands of redirects = thousands of syscalls

Next → Appending to Files

Updated: