Bash scripting guide · part 8 of 32
Command substitution
Capturing a command's output with $(...), what happens to trailing newlines and stderr, how to check the exit status, and the local trap that hides failures.
$(command) runs a command and replaces itself with what the command printed to standard
output. It is how you get data from programs into variables and arguments:
today=$(date +%F), cd "$(dirname "$0")", count=$(grep -c ERROR logs/app.log).
$( ) rather than backticks
The older spelling is `command`. It still works, but nesting needs escaped backticks,
backslashes inside behave differently, and it is easy to misread as a single quote. Use $( )
in new code; it nests naturally and starts a fresh quoting context.
Capture output in a variable
errors=$(grep -c ERROR logs/app.log)
latest=$(tail -n 1 logs/app.log)
echo "errors: $errors"
echo "latest: $latest"
echo "owner of notes.txt: $(stat -c %U "$(readlink -f link-to-notes)")"
errors: 2 latest: 2026-03-14 08:30:05 INFO cleanup job removed 42 temp files owner of notes.txt: dev
Trailing newlines are removed
Almost every command ends its output with a newline, so bash strips all trailing newlines from the result. Newlines in the middle are kept. This is convenient for one-line outputs and occasionally surprising when the trailing blank lines were data.
Trailing newlines vanish, inner ones stay
out=$(printf 'line 1\nline 2\n\n\n')
printf '[%s]\n' "$out"
printf 'chars: %d\n' "${#out}"
keep=$(printf 'data\n\n'; printf x); keep=${keep%x}
printf 'with sentinel: %d chars\n' "${#keep}"
[line 1 line 2] chars: 13 with sentinel: 6 chars
The three trailing newlines were dropped; the one between the lines survived, giving 13
characters. When trailing newlines matter, print a sentinel character after the output and
strip it with ${keep%x}, which keeps data plus both newlines, 6 characters.
Quote the substitution
An unquoted $( ) is split into words and globbed like an unquoted variable
(Word splitting). Multi-line output collapses onto one line, and a * in the output
turns into filenames. Wrap it in double quotes unless you specifically want splitting.
Unquoted output loses its line structure
echo $(head -n 3 todo.txt)
echo "$(head -n 3 todo.txt)"
buy milk fix login bug write tests buy milk fix login bug write tests
Unquoted, the three lines were split into words and echo joined them with single spaces.
Quoted, the output is passed as one argument with its newlines.
Only stdout is captured
Error messages go to standard error, which is not captured: they still appear on the terminal
and the variable gets only what went to stdout. Redirect 2>&1 inside the substitution when you
want both, or 2>/dev/null to discard errors.
Errors bypass the capture
out=$(ls notes.txt missing.txt)
echo "captured: [$out]"
both=$(ls notes.txt missing.txt 2>&1)
echo "captured: [$both]"
ls: cannot access 'missing.txt': No such file or directory captured: [notes.txt] captured: [ls: cannot access 'missing.txt': No such file or directory notes.txt]
In the first run, the ls error reached the output directly and only notes.txt was
captured. With 2>&1 inside the parentheses, the error message is part of the value.
The exit status survives an assignment
A plain assignment var=$(cmd) sets $? to the status of cmd, so you can capture output
and test success in one step with if var=$(cmd); then. That is not true for local,
export, declare and readonly: they are commands themselves, and their own success
(always 0) replaces the substitution's status.
Test the command inside an assignment
if version=$(jq -r .version data/config.json); then
echo "version $version"
fi
if missing=$(jq -r .version data/nope.json 2>/dev/null); then
echo "found"
else
echo "jq failed with status $?"
fi
version 2.4.1 jq failed with status 2
The assignment passes jq's exit status through to if. The second jq could not open the
file, so the else branch ran and $? still holds jq's status, 2.
local hides the failure
bad() { local out=$(false); echo "local form: status $?"; }
good() { local out; out=$(false); echo "split form: status $?"; }
bad; good
local form: status 0 split form: status 1
In bad, $? reports local succeeding, so the failure of false is lost; under
set -e the script would not stop either. Declaring first and assigning on a separate line
keeps the real status. ShellCheck warns about this as SC2155.
It runs in a subshell
The command inside $( ) runs in a copy of the shell. It can read your variables, but
assignments, cd and other changes it makes are discarded when it finishes.
Changes inside $( ) do not persist
n=1
out=$(n=2; cd /tmp; echo "inside: n=$n pwd=$PWD")
echo "$out"
echo "outside: n=$n pwd=$PWD"
inside: n=2 pwd=/tmp outside: n=1 pwd=/home/dev/demo
Inside, n and the directory changed; outside, neither did. A function that "returns" a value
by printing it works with $( ), but one that sets a global variable must be called directly.
Reading a whole file
$(< file) is a bash shortcut for $(cat file) that reads the file without starting cat.
Like any substitution it drops trailing newlines, which makes it neat for single-value files
such as PID files, tokens and version strings.
Read a small file into a variable
printf '2.4.1\n' > VERSION
version=$(< VERSION)
echo "releasing v$version"
tag="v$(< VERSION)-$(date -u +%Y%m%d)"
echo "${tag%-*}"
releasing v2.4.1 v2.4.1
The file's trailing newline is gone, so the value can be dropped straight into a longer
string. The second line builds a tag from the file and the date, and the last echo strips
the date part again to keep the output stable.
Cost in loops
Each $( ) forks a subshell, and usually an external program too. Calling $(basename "$f") or
$(echo "$x" | tr a-z A-Z) thousands of times in a loop is slow. The parameter expansions
${f##*/} and ${x^^} do the same work inside the shell (Parameter expansion).
For capturing many lines into an array, use mapfile -t arr < <(cmd) rather than splitting
a substitution (Reading files line by line).
Related
echoPrint its arguments separated by spaces, followed by a newline.printfPrint text and values through a format string, with precise control over spacing, numbers and escapes.readlinkPrint where a symbolic link points, or with -f the fully resolved path of any file.statShow a file's metadata (size, permissions, owner, inode, timestamps), or print chosen fields in your own format.jqQuery, filter and reshape JSON from the command line with a small expression language.mapfileRead lines from standard input into an indexed array, one line per element (also called readarray).- QuotingSingle quotes, double quotes, backslashes and $'...', what each protects, and the unquoted-variable bugs they prevent, each shown failing and then fixed.
- Word splittingWhen bash splits expansion results into separate words, how IFS controls it, and why
for line in $(cat file)does not loop over lines. - Subshells and command groupingThe difference between ( ... ) and { ...; }, what a subshell inherits and what it cannot change, which constructs create one, and what that costs.
- Process substitutionUse <(cmd) and >(cmd) to hand a command's output to a program that wants a file name, keep while-read loops in the current shell, and avoid its timing traps.
- Parameter expansionThe ${...} operators for defaults, lengths, substrings, trimming prefixes and suffixes, search and replace, and case conversion, without calling sed or cut.