| |

Bash Scripting 04 ๐Ÿง Command Substitution โ€” Capturing command output into variables

โฑ๏ธ Estimated read: 9 min | |

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

FormStatus
$(command)The modern
`command`The legacy
< <(command)The process substitution

The Capturing

FormBehavior
VAR=$(cmd)The string
"$(cmd)"The string
$VAR (unquoted)The split
"$VAR"The single string

The Exit Code

FormResult
VAR=$(cmd)The $? is the cmd exit
VAR=$(false)The $? is the 1

The Trailing Newline

FormResult
$(printf "a\n")a
$(printf "\na\n")\na
The x trickPreserves 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

PitfallWhy It HappensFix
Filenames with spaces breakUnquoted $VAR"$VAR" or while read
The variable empty after the loopThe pipe subshellThe < <(cmd)
The nested backtick failsThe escapingThe $(...)
The trailing newline lostThe strippingThe x trick
The exit code wrongThe intervening commandCapture 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

ItemValue
The modern syntax$(command)
The legacy syntax`command`
The process substitution< <(command)
The quotes"$(command)"
The exit codeThe $?
The trailing newlineStripped
The subshellThe $() runs in the
The pipeThe `

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. The VAR=$(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 x trick preserves the trailing.
  • The exit code is the command’s. The VAR=$(false) sets the $? to the 1. The if VAR=$(cmd); then checks 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.

Was this helpful?


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!