Variables, Quoting and Expansion · about 40 min

Command substitution and arithmetic

Why this matters

Scripts constantly need to use one command's result inside another command: "name the archive after today's date", "warn if the disk has more than 1000 files", "how many errors did the log show today?". Command substitution captures a command's output as text, and arithmetic expansion lets you compute with it. Together with variables, they turn the shell from a command launcher into a small programming language.

Command substitution: $(command)

$(command) runs the command and is replaced by whatever it printed on standard output:

$ echo "Today is $(date +%A)."
Today is Sunday.
$ files=$(ls /etc | wc -l)
$ echo "There are $files entries in /etc"
There are 93 entries in /etc
$ touch "report-$(date +%F).txt"
$ ls
report-2026-11-01.txt

date +FORMAT prints the date in a chosen format (%F is YYYY-MM-DD, %H:%M the time; see date --help). Note the command inside $(...) can be anything, pipelines included.

What happens underneath

To run the inner command, bash creates a subshell: it forks a copy of itself, connects the copy's standard output to a pipe, and reads everything that comes through the pipe until the child exits.

 bash ─── fork ───▶ subshell (copy of bash) ── runs `ls /etc | wc -l`
   ▲                   stdout ──┐
   │                            │ pipe
   └──── reads "93\n" ──────────┘   then removes trailing newlines → "93"

Consequences:

Quoting command substitution

The result of an unquoted $(...) is word-split and globbed, exactly like an unquoted variable (lesson 4). Quote it unless you want separate words:

$ echo $(printf 'line one\nline two')
line one line two
$ echo "$(printf 'line one\nline two')"
line one
line two

Quotes nest naturally inside $( ): the inner command is parsed fresh, so "$(head -n 1 "$file")" is correct and common.

The old form: backticks

You will see `command` in older scripts. It means the same as $(command), but nesting needs escaping and backslashes behave strangely inside it. Always write $(...) in new code:

n=`wc -l < file`          # old
n=$(wc -l < file)         # same, preferred
dir=$(basename "$(pwd)")  # nesting is easy with $( )

Arithmetic expansion: $(( expression ))

bash has built-in integer arithmetic:

$ echo $(( 2 + 3 * 4 ))
14
$ echo $(( (2 + 3) * 4 ))
20
$ echo $(( 17 / 5 )) $(( 17 % 5 )) $(( 2 ** 10 ))
3 2 1024
$ echo $(( -7 / 2 ))
-3

The operators are the familiar ones from C: + - * / % (remainder), ** (power), comparison operators that yield 1 or 0 (<, >, <=, >=, ==, !=), &&, ||, and parentheses. Division throws away the fractional part, rounding toward zero.

Inside $(( )) variables can be used with or without $; the name alone is looked up and its value read as a number. An unset or empty variable counts as 0:

$ a=7; b=3
$ echo $(( a * b )) $(( $a - $b ))
21 4
$ echo $(( nosuchvar + 1 ))
1

Assignment operators work too: $(( count = count + 1 )), $(( count += 5 )). (Module 9 introduces (( )) without the $, for doing arithmetic as a statement and for numeric tests.)

Numbers with a leading zero are read as octal, and 0x as hexadecimal, a frequent trap with dates and padded numbers:

$ echo $(( 010 ))
8
$ echo $(( 08 ))
bash: 08: value too great for base (error token is "08")
$ echo $(( 10#08 ))
8

10# forces base 10. Remember this when you do arithmetic on date +%m (which gives 08 in August).

Integers are 64-bit, so they overflow silently past about 9.2 quintillion; that rarely matters in practice.

Decimals: bc

For fractions, hand the calculation to bc, the calculator program, via a pipe:

$ echo 'scale=2; 22 / 7' | bc
3.14
$ ratio=$(echo "scale=1; 12 * 100 / 42" | bc)
$ echo "$ratio%"
28.5%

scale sets the number of decimal digits. (awk, from the text processing module, is another option: awk 'BEGIN { printf "%.1f\n", 12*100/42 }'.)

Putting them together

A small report, built only from what you know so far (on a typical Debian system, where the package manager's log exists):

log=/var/log/dpkg.log
total=$(wc -l < "$log")
installs=$(grep -c ' install ' "$log")
echo "$installs of $total log lines are installs ($(( installs * 100 / total ))%)"
182 of 2731 log lines are installs (6%)

Common mistakes

Exercises

The code exercise is also tested against different files, so compute everything. The task uses the real date in the sandbox.

Recap

Going further: date +%s prints seconds since 1970 (the Unix epoch). Use two of them and $(( )) to time a command: start=$(date +%s); sleep 2; echo $(( $(date +%s) - start )). Then read date --help for more format codes.

Exercises

Exercise 1 · quiz

Integer arithmetic

What does echo $(( 7 / 2 * 2 )) print?

Answer
  • 6

bash arithmetic is integer-only. / and * have the same precedence and group left to right: 7 / 2 is 3 (the fraction is thrown away, rounding toward zero), and 3 * 2 is 6. If you need the remainder, use %: $(( 7 % 2 )) is 1. For decimals, use bc or awk.

Exercise 2 · write code

Count and compute

The working directory contains a.txt and b.txt. Write a script that counts the lines of each with wc -l < file, and prints exactly:

a.txt: 12 lines
b.txt: 30 lines
total: 42 lines
a.txt share: 28%

The numbers must be computed, not typed in: the checker also runs your script against other files (so a script that just echoes these numbers will fail). The share is a * 100 / total in integer arithmetic.

File: script.sh

Starting point

a=$(wc -l < a.txt)
echo "a.txt: $a lines"

Example

expected output

a.txt: 12 lines
b.txt: 30 lines
total: 42 lines
a.txt share: 28%
Hint 1

Capture each count in a variable with $(wc -l < file). Using < file makes wc print just the number, without the file name.

Hint 2

total=$(( a + b )). Inside $(( )) you can write variable names without $.

Reference solution
a=$(wc -l < a.txt)
b=$(wc -l < b.txt)
total=$(( a + b ))
echo "a.txt: $a lines"
echo "b.txt: $b lines"
echo "total: $total lines"
echo "a.txt share: $(( a * 100 / total ))%"

Exercise 3 · task in a shell

A file named after today

In the shell, create an empty file in /work named backup-YYYY-MM-DD.txt, where YYYY-MM-DD is today's date in the sandbox, produced by command substitution with date +%F (do not type the date yourself).

Then save the number of entries in /etc (as counted by ls /etc | wc -l) into a file named etc-count.txt, as the line etc has N entries.

Hint 1

date +%F prints the date as 2026-11-01. $(date +%F) puts that output into a command line.

Hint 2

touch "backup-$(date +%F).txt" and echo "etc has $(ls /etc | wc -l) entries" > etc-count.txt

Reference solution
touch "backup-$(date +%F).txt"
echo "etc has $(ls /etc | wc -l) entries" > etc-count.txt

Exercise 4 · quiz

Where did the blank lines go?

printf 'a\n\n\n' prints a followed by three newlines. How many newline characters does x=$(printf 'a\n\n\n') store in x?

Type a number.

Answer

Zero. Command substitution removes all trailing newlines from the output, which is why $(date) or $(wc -l < f) drop neatly into the middle of a line. Newlines in the middle of the output are kept. If trailing newlines matter (rare), a common trick is x=$(cmd; echo .); x=${x%.}, which protects them with a dot and strips it afterwards.