Skip to content
VLSI Mentor

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

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
$ 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:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
$ 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.

What command substitution does, in four stages. The command inside runs first, in a subshell of its own. Everything it prints to normal output is captured. Every newline at the very end is removed. What is left is pasted into the command line in place of the substitution.The inner commandrunsin a subshell of its ownIts normal output iscapturederror output is not — itstill reaches your screenTrailing newlinesare removedevery one of them, at thevery end onlyThe text is pastedinwhere the substitutionstood12
Figure 1 — what command substitution does, in four stages. The inner command runs first, in a subshell of its own, so anything it assigns is lost when it finishes. Only its normal output is captured — error output goes to your screen as usual. Every newline at the very end is removed, though newlines in the middle survive. What remains is pasted into the command line where the substitution stood, at which point ordinary quoting rules decide how many arguments it becomes.

Stage 1 — it runs in a subshell

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
$ 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:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
$ 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:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
$ v=$(ls /nonexistent 2>/dev/null) || echo "that command failed"
that command failed

Stage 3 — trailing newlines are stripped

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
$ v=$(printf "hello\n\n\n")
$ echo "[$v]"
[hello]
$ echo "length=${#v}"
length=5

Three 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:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
$ 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:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
$ echo $(cat data.txt)
line one line two line three

$ echo "$(cat data.txt)"
line one
line two
line three

Unquoted, 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:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
$ 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:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
$ echo "newest: $(basename "$(ls -t *.log | head -1)")"
newest: run_42.log

Two 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.

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
$ 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:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
$ 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:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
$ count=$(wc -l < data.txt)
$ count=$(( count ))
$ echo "count=[$count]"
count=[3]
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
$ 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:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
$ 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:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
$ 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:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
if ! branch=$(git rev-parse --abbrev-ref HEAD 2>/dev/null); then
  echo "not in a git repository" >&2
  exit 1
fi

7. Putting It Together

A script header that works out where it is, what it is called, and when it ran — entirely from substitutions:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
#!/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:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
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:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
for sv in "${script_dir}"/rtl/*.sv; do
  name=$(basename "$sv" .sv)
  lines=$(grep -c "" "$sv")
  echo "${name}: ${lines} lines"
done

Note 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.

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
today=$(date +%F)
here=$(pwd)
me=$(whoami)
echo "$me in $here on $today"

2 — Prove the subshell.

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
x=outer
y=$(x=inner; echo "$x")
echo "captured=[$y]  parent=[$x]"

3 — Prove trailing newlines are stripped and interior ones are not.

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
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.

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
echo $(cat three.txt)
echo "$(cat three.txt)"

5 — Reproduce the counting problem and fix it three ways.

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
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.

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
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.

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
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 cd or 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 file includes the file name; BSD wc pads 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.