Linux & Bash · Part I · Module 8 · Variables, Command Substitution and Arithmetic
Command Substitution: a Command's Output as a Value
Up to now a command's output has gone to your screen and stopped there. Command substitution catches it instead and hands it back as text you can store, compare and build paths out of — which is the step that makes a shell script a program rather than a list.
Intermediate19 min readLinuxPart IFundamentalsVariablesBash
Part I · Module 8 · Chapter 8.4 · Command Substitution: a Command's Output as a Value
Every value so far has been one you typed. This chapter is about values the machine works out — today's date, how many tests failed, where a tool actually lives — and it is the most important construct in the module.
1. The Construct
$ today=$(date +%F)
$ echo "$today"
2026-10-07$(command) runs the command and becomes whatever it printed.
The shell runs date +%F, catches the text it produced, and puts that text where the $(...) stood. By the time the assignment happens there is no command any more, only its output.
It works anywhere a value can appear, not just in assignments:
$ echo "the run started at $(date +%H:%M)"
the run started at 13:13
$ mkdir -p "logs/$(date +%Y-%m-%d)"
$ wc -l "$(ls -t *.log | head -1)"That last one reads as "count the lines in the newest log file" — two commands composed into one, with no temporary variable.
2. The Four Stages
Every surprise in this chapter is one of four stages doing exactly what it is specified to do.
Stage 1 — it runs in a subshell
$ x=outer
$ y=$(x=inner; echo "$x")
$ echo "captured=[$y] parent x=[$x]"
captured=[inner] parent x=[outer]The inner command ran as a child process (Chapter 7.4), so it got its own copy of everything and its assignment died with it. The output came back; the state did not.
This is the same one-way rule you have already met twice, and it is the reason you cannot set a variable from inside a substitution and expect it afterwards.
Stage 2 — only normal output is captured
Error output is a separate stream (Module 12 covers both properly) and it is not captured:
$ v=$(ls /nonexistent)
ls: /nonexistent: No such file or directory
$ echo "v=[$v]"
v=[]The error message went straight to your terminal — it is not in v, and in a script it will appear in the log looking like a loose complaint with no context. The value is empty.
This is useful, because it means an error message never silently becomes part of a path. It is also a trap, because an empty value is exactly what Chapter 8.2 warned about. Check the status rather than the value:
$ v=$(ls /nonexistent 2>/dev/null) || echo "that command failed"
that command failedStage 3 — trailing newlines are stripped
$ v=$(printf "hello\n\n\n")
$ echo "[$v]"
[hello]
$ echo "length=${#v}"
length=5Three trailing newlines, all removed. Five characters, not eight.
This is almost always what you want — a command's output ends in a newline, and you want the value without it. But note the precision: only at the very end. Newlines in the middle survive:
$ v=$(cat data.txt)
$ echo "length=${#v}"
length=28
$ echo "[$v]"
[line one
line two
line three]Three lines in, three lines out, with the final newline removed — so the value is 28 characters where the file is 29 bytes.
Stage 4 — the result is pasted in
And once it is pasted, Chapter 8.1's rules apply in full:
$ echo $(cat data.txt)
line one line two line three
$ echo "$(cat data.txt)"
line one
line two
line threeUnquoted, the three-line value was split at every space and newline into nine separate arguments, which echo then printed with single spaces between them. The newlines were not removed — the splitting replaced them. Quoted, the value arrives whole.
3. Use $( ), Not Backticks
There is an older spelling:
$ today=`date +%F`It still works, you will see it in old scripts, and you should not write it. Two concrete reasons.
It does not nest. The modern form does:
$ echo "newest: $(basename "$(ls -t *.log | head -1)")"
newest: run_42.logTwo substitutions and two sets of quotes, each clearly delimited. With backticks the inner ones have to be escaped, and a second level of nesting becomes close to unreadable.
Quoting inside behaves differently. Backslashes are processed before the inner command sees them, so the rules for escaping within backticks differ from the rules everywhere else — which is a category of bug that simply does not exist with $(...).
The modern form has been standard for over thirty years. The only reason to recognise backticks is to read other people's scripts.
4. The Pattern That Produces Wrong Numbers
A verified example worth working through, because it catches almost everyone once.
$ count=$(wc -l data.txt)
$ echo "count=[$count]"
count=[ 3 data.txt]The file name is in the value. wc prints the name whenever it is given one — which makes the output friendly for a human and useless as a number.
Redirect the file into wc instead, so it has no name to print:
$ count=$(wc -l < data.txt)
$ echo "count=[$count]"
count=[ 3]Better, and still not clean: on BSD systems — macOS among them — wc pads its number with spaces. GNU wc does not. A value of 3 on your machine and 3 on a colleague's is the kind of difference that breaks a comparison in a way nobody can reproduce.
Two ways to get a plain integer:
$ count=$(wc -l < data.txt)
$ count=$(( count ))
$ echo "count=[$count]"
count=[3]$ count=$(grep -c "" data.txt)
$ echo "count=[$count]"
count=[3]Passing the value through $(( )) — Chapter 8.5 — strips whitespace as a side effect of parsing it as a number, which is both a tidy trick and a reasonable thing to do to something you intend to use as a number anyway.
5. Reading a File
A small specialisation worth knowing:
$ v=$(cat data.txt)
$ v=$(< data.txt)Both give the same value. $(< file) is a Bash shortcut that reads the file directly without starting cat — the same economy as Chapter 8.3's expansions versus basename.
Useful for small files. For anything large you want to process a line at a time rather than loading the lot into a variable, which is Chapter 8.6 and Chapter 17.5.
6. Exit Status
The status of an assignment is the status of the inner command:
$ v=$(false)
$ echo "status: $?"
status: 1
$ v=$(grep nothing data.txt)
$ echo "status=$? value=[$v]"
status=1 value=[]So you can test whether the capture worked:
if ! branch=$(git rev-parse --abbrev-ref HEAD 2>/dev/null); then
echo "not in a git repository" >&2
exit 1
fi7. Putting It Together
A script header that works out where it is, what it is called, and when it ran — entirely from substitutions:
#!/bin/bash
set -euo pipefail
script_dir=$(cd "$(dirname "$0")" && pwd)
run_id="run_$(date +%Y%m%d_%H%M%S)"
host=$(hostname -s)
logdir="${script_dir}/logs/${run_id}"
mkdir -p "$logdir"
echo "script dir : ${script_dir}"
echo "run id : ${run_id}"
echo "host : ${host}"
echo "log dir : ${logdir}"The first line is the interesting one and a genuine idiom:
script_dir=$(cd "$(dirname "$0")" && pwd)Read it inside out. $0 is how the script was invoked, possibly relatively. dirname takes its directory. cd into that directory, and pwd prints the absolute path — which is captured. The cd happened in a subshell, so your actual working directory is untouched; stage 1 is doing exactly the job you want here rather than being an obstacle.
The result is the absolute directory the script lives in, regardless of where it was run from — which is what you need before any relative path in a script is trustworthy.
And then a loop that uses the result:
for sv in "${script_dir}"/rtl/*.sv; do
name=$(basename "$sv" .sv)
lines=$(grep -c "" "$sv")
echo "${name}: ${lines} lines"
doneNote basename "$sv" .sv — the second argument strips a suffix, which is the one thing the expansion forms cannot do in a single step. Both tools, each where it is better.
Practice
1 — Capture something.
today=$(date +%F)
here=$(pwd)
me=$(whoami)
echo "$me in $here on $today"2 — Prove the subshell.
x=outer
y=$(x=inner; echo "$x")
echo "captured=[$y] parent=[$x]"3 — Prove trailing newlines are stripped and interior ones are not.
v=$(printf "hello\n\n\n"); echo "length=${#v}"
printf 'a\nb\nc\n' > three.txt
w=$(cat three.txt); echo "length=${#w}"; echo "[$w]"4 — Watch quoting change the shape of the output.
echo $(cat three.txt)
echo "$(cat three.txt)"5 — Reproduce the counting problem and fix it three ways.
wc -l three.txt
c=$(wc -l three.txt); echo "[$c]"
c=$(wc -l < three.txt); echo "[$c]"
c=$(( c )); echo "[$c]"
c=$(grep -c "" three.txt); echo "[$c]"6 — Nest.
echo "newest here: $(basename "$(ls -t | head -1)")"Then try writing the same thing with backticks and see how far you get.
7 — Watch the status disappear.
v=$(false); echo "assigned: $?"
echo "embedded $(false)"; echo "echoed: $?"8 — Find a script's own directory. Put the script_dir line in a script, run it from three different directories, and confirm it prints the same answer each time.
Check Your Understanding
1. n=$(wc -l report.txt) and n turns out to be 42 report.txt. What went wrong and what are two fixes?
2. d=$(cd /tmp && pwd) — afterwards, which directory is your shell in, and why?
3. A file ends with three blank lines. You capture it with $(cat file). How many trailing newlines are in the value?
4. echo $(cat three_line_file) prints everything on one line. Were the newlines removed by the substitution?
5. v=$(some-tool --version) leaves v empty and an error message appears on your screen. Explain both.
6. Why is echo "built at $(date)" unable to tell you whether date failed?
8. Remember
$(command)becomes whatever the command printed. It works anywhere a value can appear.- Four stages: it runs in a subshell, normal output is captured, trailing newlines are stripped, the result is pasted in.
- The subshell means state does not come back — only output. A
cdor an assignment inside affects nothing outside. - Error output is not captured. It reaches your screen while the value stays empty, so check the status rather than the value.
- All trailing newlines go; interior ones stay. A three-line file gives a three-line value with no final newline.
- Quote it:
"$(command)". Unquoted, the result is split into arguments at every space and newline. - Use
$( ), never backticks. Backticks do not nest and have their own escaping rules. - Look at what a command prints before capturing it.
wc -l fileincludes the file name; BSDwcpads with spaces. $(( value ))strips whitespace from something you intend to use as a number.$(< file)reads a file with no process at all.- An assignment's exit status is the inner command's — but only when the substitution is the command. Embedded in a larger line, the failure is invisible.
$(cd "$(dirname "$0")" && pwd)gives a script its own absolute directory, using the subshell deliberately.
The shell can now compute with text. The next chapter gives it the other half — arithmetic — along with the two things about it that bite hardest: there is no floating point, and a number with a leading zero is not the number you think it is.
Where this fits
Part of the Linux & Bash curriculum.
