Linux Basic — Capturing Output

3 minute read

Capturing Output

Before we can write sed and awk into a script, we have to capture their output. That sounds trivial — and most of the time it is — but there are three gotchas that bite every shell programmer: trailing newlines, exit codes, and multi-line results.


$(...) — The Modern Form

date_str=$(date +%F)
echo "$date_str"
# 2026-06-17

hostname=$(hostname)
files_count=$(ls | wc -l)

Always prefer $(...) over backticks:

Feature `cmd` $(cmd)
Nesting painful — must escape ` trivial: $(a $(b))
Readability poor good
Quoting inside error-prone normal

Use backticks only when forced by an ancient /bin/sh environment.


The Trailing-Newline Trap

Bash strips all trailing newlines from a command substitution result.

greet=$(printf "hello\n\n\n")
echo "[$greet]"
# [hello]                    ← three newlines vanished

Usually that’s exactly what you want. But it’s a problem when the output legitimately ends in a newline that matters:

# Capture a file with a trailing blank line
text=$(cat /tmp/note.txt)
echo "$text" > /tmp/copy.txt
# Trailing blank line is lost in /tmp/copy.txt

Workaround: append a sentinel before capturing, strip it after:

text=$(cat /tmp/note.txt; printf x)
text=${text%x}

For binary data, don’t use command substitution at all — pipe directly or use mapfile.


Capturing Output AND Exit Code

output=$(some_cmd 2>&1)
exit_code=$?

if (( exit_code != 0 )); then
    echo "command failed: $output" >&2
    exit "$exit_code"
fi

Note the order: $? is set by $(...), so grab it on the very next line. Anything in between (like an echo) overwrites it.

Common combined form:

if output=$(some_cmd 2>&1); then
    echo "OK: $output"
else
    echo "FAIL: $output" >&2
fi

Capturing Multi-Line Output

Naive:

files=$(ls)
echo "$files"
# a.txt
# b.txt
# c.txt

That’s a single string with embedded newlines — fine for echo, but to iterate safely (handles spaces, special chars), use mapfile:

mapfile -t files < <(ls)
for f in "${files[@]}"; do
    echo "[$f]"
done

<(...) is process substitution — covered in detail in Process Substitution (Chapter 11). For now, treat it as “use this command’s output as if it were a file.”

For paths specifically, prefer NUL-delimited:

mapfile -d '' -t files < <(find . -type f -print0)
echo "Found ${#files[@]} files"

Why for f in $(ls) Is Wrong

You’ll see this everywhere. It’s broken:

# ❌ Splits on whitespace — breaks on filenames with spaces
for f in $(ls); do
    echo "[$f]"
done

If a filename is "my doc.txt", you’ll iterate over [my] and [doc.txt] separately. The correct form:

# ✅ Don't parse ls; use a glob
for f in *; do
    [[ -e "$f" ]] || continue       # in case the glob doesn't match
    echo "[$f]"
done

Or for a recursive case:

while IFS= read -r -d '' f; do
    echo "[$f]"
done < <(find . -type f -print0)

Read One Line into a Variable

When the output is a single line (or you only want the first):

ip=$(hostname -I | awk '{print $1}')
echo "$ip"
# 192.168.1.42

For purely “first line” use:

read -r first_line < <(generate_output)

For “first word only”:

read -r first _ < <(echo "alice bob carol")
echo "$first"
# alice

Performance Note — Avoid Substitution in Tight Loops

Every $(...) forks a subshell. That’s free for one-shot scripts, expensive in loops:

# ❌ 10,000 forks
for i in {1..10000}; do
    val=$(date +%s%N)
done
# real 0m12.4s

# ✅ Use printf -v (no fork) when possible
printf -v ts '%(%s)T' -1     # Bash 4.2+ built-in time formatter

For sed/awk inside a loop, see if you can move the loop inside awk instead — covered in awk in Scripts.


Capturing into a Variable AND Streaming to Stdout

Use tee:

output=$(some_cmd | tee /dev/tty)

/dev/tty is the user’s terminal — handy when you want them to see progress and you also want to keep the data.


A Real Example — Wrap a Command Safely

run() {
    local name=$1
    shift
    local output exit_code

    if ! output=$("$@" 2>&1); then
        exit_code=$?
        printf '[FAIL] %s (exit %d)\n%s\n' "$name" "$exit_code" "$output" >&2
        return "$exit_code"
    fi
    printf '[OK]   %s\n' "$name"
    [[ -n "$output" ]] && printf '  %s\n' "$output"
}

run "Ping" ping -c1 example.com
run "Disk free" df -h /
run "Bad cmd" false

Common Mistakes

❌ Wrong ✅ Right Issue
`cmd` (backticks) $(cmd) nesting and readability
for f in $(ls) for f in * or mapfile … < <(find … -print0) breaks on whitespace
output=$(cmd) then echo … then check $? capture both in one line $? is overwritten by echo
text=$(cat file) when trailing newline matters use mapfile or the x sentinel trick Bash strips trailing \n
var=$(cmd) in a 10k-iteration loop rewrite to a single awk/sed fork overhead dominates

Next → sed in Scripts

Updated: