Bash Scripting 04 ๐ง Command Substitution โ Capturing command output into variables
The previous chapter covered the user input from the environment and the interactive prompts. This chapter covers the mechanism that captures the output of a command into a variable: the command substitution. In Bash, there are two syntaxes: the modern $(command) and the legacy backtick `command`. The modern syntax is preferred because it nests cleanly and does not interfere with the single quotes. The critical distinction is between the command substitution (the output capture) and the process substitution (the <(command) that creates the temporary file descriptor). The misuse of the command substitution without the quotes is the source of the word splitting and the globbing bugs. The command substitution creates the subshell, and the variable assignments inside the substitution do not affect the parent shell.
Key point: The VAR=$(command) captures the standard output of the command into the variable. The trailing newlines are stripped. The VAR=$(command) without the quotes is subject to the word splitting and the globbing. The "$(command)" preserves the output as the single string. The exit code of the substitution is the exit code of the command. The $(command1; command2) runs multiple commands. The legacy backtick form `command` is deprecated, but still works. The process substitution < <(command) creates the file descriptor, not the variable.
Why command substitution requires a specific mental model
The word splitting problem. The FILES=$(ls) assigns the entire output to the FILES variable, but the for f in $FILES splits the output on the whitespace, which breaks the filenames with the spaces. The for f in "$FILES" treats the entire output as the single string, which iterates once. The correct form for the line-by-line is the while IFS= read -r line .
The nested problem. The legacy backticks do not nest cleanly. The `echo \`date\ “ requires the escaping. The $(echo $(date)) is the clean. The modern syntax is the preferred .
The subshell problem. The $(...) runs in the subshell. The variable assignments inside the substitution do not affect the parent shell. The VAR=$(x=5; echo $x) assigns the 5 to the VAR, but the x is the local to the subshell. The while read with the pipe also runs in the subshell, which is the common source of the “the variable is empty after the loop” bug .
The exit code problem. The VAR=$(false) sets the $? to the 1 (the exit code of the false). The VAR=$(false) && echo ok does not print the ok. The exit code of the substitution is the exit code of the last command in the substitution .
The trailing newline problem. The command substitution strips the trailing newlines. The VAR=$(echo "hello") assigns the "hello", not the "hello\n". The VAR=$(printf "hello\n") also strips the trailing newline. The leading and internal newlines are preserved. The VAR=$(printf "a\nb\n") assigns the "a\nb" .
The trade-off. The command substitution is the simple and the standard. The process substitution is the correct for the commands that expect the file arguments. The diff <(cmd1) <(cmd2) is the example. The two are the different tools for the different use cases.
a. The basic command substitution
The $(...) captures the standard output.
#!/bin/bash
DATE=$(date +%Y-%m-%d)
echo "Today: $DATE"
UPTIME=$(uptime -p)
echo "Uptime: $UPTIME"
KERNEL=$(uname -r)
echo "Kernel: $KERNEL"
The $(date +%Y-%m-%d) runs the date and assigns the output. The trailing newline is stripped. The $DATE is the string.
The multiple variables.
#!/bin/bash
read -r USER_NAME USER_ID <<< "$(id -un; id -u)"
echo "User: $USER_NAME, ID: $USER_ID"
The $(id -un; id -u) runs two commands and captures both outputs. The here-string <<< passes the result to the read. The read assigns the lines to the variables.
The legacy backtick.
#!/bin/bash
DATE=`date +%Y-%m-%d`
echo "Today: $DATE"
The backtick form works, but the $(...) is the preferred. The nested backticks require the escaping, which is the error-prone.
b. The word splitting and the quoting
The unquoted substitution splits on the whitespace.
#!/bin/bash
FILES=$(ls)
echo "$FILES"
# The entire output as the single string
for f in $FILES; do
echo "File: $f"
done
# The output is split on the whitespace, so the filenames with the spaces break
The $FILES in the for loop is the unquoted, so the split. The "$FILES" is the single string. The correct for the line-by-line is the while IFS= read.
#!/bin/bash
ls | while IFS= read -r file; do
echo "File: $file"
done
The ls | while read runs the loop in the subshell (the pipe). The variable assignments inside the loop are lost after the loop. The < <(ls) is the process substitution, which keeps the loop in the current shell.
#!/bin/bash
while IFS= read -r file; do
echo "File: $file"
done < <(ls)
The < <(ls) is the process substitution. The loop runs in the current shell. The variables set inside are visible after.
c. The exit code and the trailing newlines
The exit code of the substitution is the exit code of the command.
#!/bin/bash
if OUTPUT=$(ls /nonexistent 2>&1); then
echo "Success: $OUTPUT"
else
echo "Failed: $OUTPUT"
echo "Exit code: $?"
fi
The $(ls /nonexistent 2>&1) captures the error output. The $? is the 2 (the ls error). The if checks the result. The OUTPUT variable holds the error message.
The trailing newline stripping.
#!/bin/bash
VAR=$(printf "hello\n\n\n")
echo "[$VAR]"
# [hello] (the trailing newlines stripped)
VAR=$(printf "\n\nhello\n")
echo "[$VAR]"
# [
#
# hello] (the leading newlines preserved)
The trailing newlines are stripped. The leading and the internal are preserved. The VAR=$(printf "hello\n") is the "hello".
The workaround for the trailing newline.
#!/bin/bash
VAR=$(printf "hello\n"; echo "x")
VAR=${VAR%x}
echo "[$VAR]"
# [hello] (the x appended and then removed)
The echo "x" appends the x. The ${VAR%x} removes the x. The trailing newline is preserved as the part before the x.
Complete Example Session
# ============================================
# PART 1: THE BASIC
# ============================================
DATE=$(date +%Y-%m-%d)
echo "Today: $DATE"
# ============================================
# PART 2: THE MULTIPLE
# ============================================
KERNEL=$(uname -r)
HOST=$(hostname)
echo "$HOST runs $KERNEL"
# ============================================
# PART 3: THE EXIT CODE
# ============================================
if OUTPUT=$(ls /nonexistent 2>&1); then
echo "OK"
else
echo "Failed: $OUTPUT"
fi
# ============================================
# PART 4: THE WORD SPLITTING
# ============================================
FILES=$(ls)
for f in $FILES; do
echo "File: $f"
done
# The filenames with the spaces break
# ============================================
# PART 5: THE QUOTED
# ============================================
echo "$FILES"
# The entire output as the single string
# ============================================
# PART 6: THE RAW LINE LOOP
# ============================================
ls | while IFS= read -r file; do
echo "File: $file"
done
# ============================================
# PART 7: THE PROCESS SUBSTITUTION
# ============================================
while IFS= read -r file; do
echo "File: $file"
done < <(ls)
# ============================================
# PART 8: THE TRAILING NEWLINE
# ============================================
VAR=$(printf "hello\n")
echo "[$VAR]"
# [hello]
# ============================================
# PART 9: THE LEGACY BACKTICK
# ============================================
DATE=`date +%Y-%m-%d`
echo "Today: $DATE"
The nine parts trace the command substitution: the basic, the multiple, the exit code, the word splitting, the quoted, the raw line loop, the process substitution, the trailing newline, and the legacy backtick.
Quick Reference
The Syntax
| Form | Status |
|---|---|
$(command) | The modern |
`command` | The legacy |
< <(command) | The process substitution |
The Capturing
| Form | Behavior |
|---|---|
VAR=$(cmd) | The string |
"$(cmd)" | The string |
$VAR (unquoted) | The split |
"$VAR" | The single string |
The Exit Code
| Form | Result |
|---|---|
VAR=$(cmd) | The $? is the cmd exit |
VAR=$(false) | The $? is the 1 |
The Trailing Newline
| Form | Result |
|---|---|
$(printf "a\n") | a |
$(printf "\na\n") | \na |
The x trick | Preserves the newline |
Best Practices
โ Do This:
DATE=$(date +%Y-%m-%d)
<!-- Use the $() for the substitution --> # โ
"$(command)"
<!-- Use the quotes for the single string --> # โ
while IFS= read -r line; do ...; done < <(cmd)
<!-- Use the process substitution for the loop --> # โ
โ Don’t Do This:
`command`
<!-- The legacy does not nest cleanly --> # โ
for f in $FILES
<!-- The unquoted splits on the whitespace --> # โ
ls | while read
<!-- The subshell loses the variables --> # โ
Common Pitfalls
| Pitfall | Why It Happens | Fix |
|---|---|---|
| Filenames with spaces break | Unquoted $VAR | "$VAR" or while read |
| The variable empty after the loop | The pipe subshell | The < <(cmd) |
| The nested backtick fails | The escaping | The $(...) |
| The trailing newline lost | The stripping | The x trick |
| The exit code wrong | The intervening command | Capture the $? immediately |
Real-World Examples
1. The Date
DATE=$(date +%Y-%m-%d)
2. The Kernel
KERNEL=$(uname -r)
3. The Exit Check
if OUT=$(cmd 2>&1); then ...
4. The Quoted
echo "$(ls)"
5. The Line Loop
while IFS= read -r f; do echo "$f"; done < <(ls)
6. The Process Substitution
diff <(cmd1) <(cmd2)
7. The Trailing Newline
VAR=$(cmd; echo x); VAR=${VAR%x}
8. The Nested
echo "$(echo $(date))"
9. The Legacy
DATE=`date`
10. The Array
ARR=($(ls))
Visual
The Command Substitution
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ VAR=$(command) โ
โโโโโโโโโโโโโโโโโโโโฌโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ
โผ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ The subshell runs the command โ
โ โ The output to the stdout โ
โโโโโโโโโโโโโโโโโโโโฌโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ
โผ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ The output captured, trailing newlines strippedโ
โ โ The VAR is the string โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
The Word Splitting
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ FILES=$(ls) โ
โ โ The string with the newlines โ
โโโโโโโโโโโโโโโโโโโโฌโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ
โโโโโโโโโโโโดโโโโโโโโโโโ
โ โ
โผ โผ
โโโโโโโโโโโโโโโโโ โโโโโโโโโโโโโโโโโ
โ for f in โ โ for f in โ
โ $FILES โ โ "$FILES" โ
โ โ Split โ โ โ One string โ
โโโโโโโโโโโโโโโโโ โโโโโโโโโโโโโโโโโ
Summary
| Item | Value |
|---|---|
| The modern syntax | $(command) |
| The legacy syntax | `command` |
| The process substitution | < <(command) |
| The quotes | "$(command)" |
| The exit code | The $? |
| The trailing newline | Stripped |
| The subshell | The $() runs in the |
| The pipe | The ` |
Key takeaways:
- The
$(...)is the modern and the clean. The nested$(echo $(date))works. The backtick nesting requires the escaping. The modern is the preferred. - The quotes prevent the word splitting. The
"$(command)"is the single string. The$(command)without the quotes splits on the whitespace. - The subshell isolates the variables. The
$(...)runs in the subshell. TheVAR=$(x=5; echo $x)does not affect the parent. - The trailing newline is stripped. The command substitution removes the trailing newlines. The leading and the internal are preserved. The
xtrick preserves the trailing. - The exit code is the command’s. The
VAR=$(false)sets the$?to the1. Theif VAR=$(cmd); thenchecks the result.
Remember: The $() is the capture. The quotes are the string. The process substitution is the file. The exit code is the result. The subshell is the isolation. The exam tests the substitution and the word splitting.
Stop using slow, ad-bloated tool sites! ๐คฎ
๐ Search “KandZ Tools” on Google to use many professional utilities for free.
KandZ.me is the ultimate minimalist hub for:
โ
Finance (Mortgage, Interest, Inflation)
โ
Tech (Base64, JSON, Dev Suite, IP)
โ
Health (BMI, BMR, TDEE)
โ
Productivity (Timer, Workspace, QR)
โก๏ธ Fast & Private
๐ No data leaves your device
๐ 100% Free
๐ Use it now: https://tools.kandz.me
๐ Bookmark itโyouโll need it later!