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/shon 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:
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
- Trailing newlines are removed, so
$(hostname)givesrocky, notrocky⏎. - They nest:
$(echo $(whoami))runs the inner one first. - Errors aren't captured. They still appear on screen. To capture them too, add
2>&1:x=$(ls /nope 2>&1)(lesson 11 explains the streams). - Save the output in a variable:
count=$(ls | wc -l). Afterwards,$?is the inner command's exit code. - Read a file quickly:
$(< /etc/hostname)is the same as$(cat /etc/hostname), without startingcat.
Backticks: the old way
Old scripts, and a lot of answers online, use backticks for the same thing:
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:
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
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:
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
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:
(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:
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:
{ date; uptime; df -h /; } > status.txt
( date; uptime ) | tee status2.txt( … ) | { …; } | |
|---|---|---|
| Runs in | A copy (subshell) | Your current shell |
cd and variables | Forgotten afterwards | Kept |
exit | Ends only the copy | Ends 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:
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:
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.
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.
| Feature | bash | dash (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) |
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?
✓ Double quotes still allow $( ). Only single quotes would print it literally.
2. You run (cd /etc), then pwd. Where are you?
✓ Use { cd /etc; } (or just cd /etc) to really move.
3. A cron job diff <(ls /a) <(ls /b) > /tmp/changes works on Rocky but does nothing on Ubuntu. Why?
✓ The error ends up in the cron log or mail, where nobody looks, which is why it's such a sneaky bug.
4. What's $(( 17 % 5 ))?
✓ % is the remainder: 17 = 3 × 5 + 2. (In date +%F it means something completely different.)