Linux Sysadmin · Lesson 12 · 25 min

Subshells & command substitution

You've already sneaked a few of these in: docs-$(date +%F).tar.gz in lesson 8, $(openssl rand -hex 32) in lesson 9. Now let's learn properly how to put one command inside another, do math, run commands in a throwaway copy of your shell, and why some of this breaks on Ubuntu but not on Rocky.

You will learn

  • $( ) command substitution, and the old backtick form ` `
  • Why quotes matter: "$(…)" vs $(…)
  • $(( )) math, ${ } tricks, ( ) subshells and { } groups
  • <( ) process substitution, and why /bin/sh on Ubuntu (dash) rejects it

$( ): put a command's output right here

Bash runs the command inside $( ) first, then pastes its output into the line, as if you had typed it yourself:

Same on both
echo "Hi $(whoami), today is $(date +%A)"
tar -czf docs-$(date +%F).tar.gz Documents      # a new file name every day
echo "This server has $(nproc) CPU cores"
ls -l $(which python3)                          # details of the program file
sudo kill $(pgrep -f kworkerd)                  # kill by name, via its PID
Hi student, today is Monday

Backticks: the old way

Old scripts, and a lot of answers online, use backticks for the same thing:

Same on both
echo "Kernel: `uname -r`"         # old style
echo "Kernel: $(uname -r)"        # same thing, modern style

They still work everywhere, but prefer $( ). Backticks look almost like single quotes ', so they're easy to misread. Nesting them needs ugly escaping (`echo \`whoami\`` vs $(echo $(whoami))). And backslashes inside them behave strangely. If you meet them in an old script, you can read them as $( ).

Quotes change everything

Without quotes, bash splits the output into separate words at every space and newline. With double quotes, it stays exactly as it was. Single quotes turn it off completely:

Same on both
files=$(ls Documents)
echo $files          # one line:  essay-draft.txt homework.txt notes.txt
echo "$files"        # one per line, exactly as ls printed them
echo '$(whoami)'     # single quotes: printed literally, nothing runs
Spaces in file names

Unquoted rm $(cat delete-list.txt) on a list containing My Essay.txt tries to delete two files, My and Essay.txt. Quote your substitutions: "$( … )". For long lists, xargs (lesson 3) is safer.

$(( )): math

Double parentheses mean arithmetic, not a command. Only whole numbers, so 7 / 2 is 3:

Same on both
echo $(( 6 * 7 ))                         # 42
echo $(( 7 / 2 )) $(( 7 % 2 )) $(( 2 ** 10 ))   # 3  1  1024   (% = remainder, ** = power)
n=5; echo $(( n + 1 ))                    # variables don't need $ inside
echo "Doubled: $(( $(ls Documents | wc -l) * 2 ))"

Easy mix-up: $(date) runs something, and $((5 + 5)) calculates something.

${ }: variable tricks

Same on both (bash)
f=/var/log/app/report.tar.gz
echo ${f##*/}          # report.tar.gz   (strip the folders, like basename)
echo ${f%/*}           # /var/log/app    (strip the file name, like dirname)
echo ${f%.tar.gz}      # /var/log/app/report   (cut an ending)
echo ${#f}             # 26   (length)
echo ${NAME:-friend}   # the value of NAME, or "friend" if it's empty
echo ${f^^}            # UPPERCASE (bash only)

The braces also mark where a name ends: "${USER}_backup" works, while "$USER_backup" looks for a variable called USER_backup.

( ) subshells: a throwaway copy

Commands in parentheses run in a subshell, a copy of your shell. Anything they change (the folder, variables) disappears when the copy ends:

Same on both
(cd /var/log && ls)      # look inside…
pwd                       # …but you're still where you were
x=1; (x=2); echo $x       # 1: the change stayed inside the copy
(exit 3); echo $?         # 3: exit only ends the subshell

$( ) is a subshell too. That's why $(cd /tmp) never moves you. So is every part of a pipe, which explains a classic puzzle:

Same on both
echo hi | read x; echo "x=$x"     # x is EMPTY: read ran in a subshell
x=$(echo hi); echo "x=$x"          # x=hi: this works

{ } groups: same shell, one output

Curly braces group commands without a subshell, which is handy for sending several outputs into one file. The spaces and the final ; are required:

Same on both
{ date; uptime; df -h /; } > status.txt
( date; uptime ) | tee status2.txt
( … ){ …; }
Runs inA copy (subshell)Your current shell
cd and variablesForgotten afterwardsKept
exitEnds only the copyEnds your shell!
Use it for“Do this over there without moving me”“Collect these outputs together”

<( ): output as a file

Some commands, like diff, want files, not piped input. Process substitution turns a command's output into a temporary file:

Same on both (bash)
diff <(ls Docs-old) <(ls Documents)                  # what changed between two folders?
diff <(sort list1.txt) <(sort list2.txt)             # compare lists, ignoring order
diff <(ssh web1 cat /etc/hosts) <(ssh web2 cat /etc/hosts)   # two servers!
echo <(true)                                         # /dev/fd/63: the "file" is really a pipe

The family difference: /bin/sh

Scripts that start with #!/bin/sh, commands run with sh -c, and every cron job (lesson 5) run in /bin/sh, not in your bash. And /bin/sh is a different program on each family:

Rocky / RHEL
ls -l /bin/sh
lrwxrwxrwx. 1 root root 4 … /bin/sh -> bash

sh is bash, so bash extras work even in #!/bin/sh scripts and cron jobs.

Ubuntu / Debian
ls -l /bin/sh
lrwxrwxrwx 1 root root 4 … /bin/sh -> dash

dash is a small, fast shell that sticks to the POSIX standard. Bash extras fail: sh: 1: Syntax error: "(" unexpected.

Featurebashdash (Ubuntu's sh)
$( ), backticks, $(( )), ( ), { }✅✅
${x:-default}, ${x%.txt}, ${x##*/}, ${#x}✅✅
<( ) process substitution✅❌ Syntax error
[[ … ]], arrays a=(1 2 3)✅❌
${x//old/new}, ${x^^}, ${x:0:3}✅❌ Bad substitution
source file✅❌ (use . file)
The fix

If a script uses bash features, say so: start it with #!/bin/bash, or run it with bash script.sh. For cron, add SHELL=/bin/bash at the top of your crontab. Then the script works the same on both families. Why does Ubuntu use dash at all? It starts much faster, and boot scripts used to run thousands of times.

Try it 🧪

Quick check

1. What does echo "Backup-$(date +%F)" print on September 27, 2026?

2. You run (cd /etc), then pwd. Where are you?

3. A cron job diff <(ls /a) <(ls /b) > /tmp/changes works on Rocky but does nothing on Ubuntu. Why?

4. What's $(( 17 % 5 ))?

Finished the missions and the quiz? Mark it done to track your progress.