SSD Nodes Learn 🎉 VPS from $4.99/mo
Guides Matt ConnorBy Matt Connor

Bash Command Substitution: $() vs Backticks

Why $(...) runs in a subshell, so cd and variables vanish, why backticks nest badly, and the process substitution tricks that keep state in your shell.

Verified Every command ran end-to-end on a fresh Ubuntu 24.04 server, August 4, 2026.

What bash command substitution does

Bash command substitution replaces $(command) with the text that command printed to standard output. The older spelling with backticks does the same job. Everything that surprises people follows from two facts: the command runs in a separate process called a subshell, and every newline at the end of its output is thrown away.

mkdir -p /tmp/subst-demo
cd /tmp/subst-demo
printf 'alpha\nbeta\ngamma\n' > three.txt
count=$(wc -l < three.txt)
echo "$count"
3

That is the whole feature. wc -l printed 3 followed by a newline, the newline was stripped, and count holds the two characters you wanted. Note that wc -l < three.txt prints a bare number because GNU wc reading standard input has no filename to print. Write wc -l three.txt instead and you capture 3 three.txt, which is a different string and a common cause of arithmetic that fails later.

The rest of this guide is the behaviour nobody expects, because a subshell is a separate process and a separate process cannot change the shell you are typing into.

Use $() and stop using backticks

Both forms are valid. $() is in POSIX, so dash, ash and busybox sh all support it. There is no portability reason left to write backticks, and there are two concrete reasons not to.

Backticks do not nest

echo "$(echo "$(echo hi)")"
echo "`echo `echo hi``"
hi
echo hi

The second line printed the literal words echo hi. The shell scans forward for the next unescaped backtick, so the second backtick you typed closed the first one. The command it actually ran was echo with no arguments, which printed an empty line that then had its newline stripped to nothing. The words echo hi were left behind as plain text, and the final pair of backticks ran an empty command.

To nest backticks you must escape every inner one:

echo "`echo \`echo hi\``"
hi

Each extra level doubles the escaping again. $() needs none of this, because the parser matches parentheses instead of scanning for a delimiter character.

Backticks change your backslashes before the command runs

echo "$(echo 'a\\b')"
echo "`echo 'a\\b'`"
a\\b
a\b

The same inner command produced different output. Inside backticks the shell removes one layer of backslash escaping before the inner text is parsed, so the single quotes protected nothing. Inside $() the text between the parentheses is parsed as an ordinary script, so single quotes behave the way you expect. This bites hardest in sed and awk one-liners, where a lost backslash turns a working pattern into a silently different one.

Quoting also starts fresh inside $(), which means you can nest double quotes without escaping them:

path=/etc/nginx/nginx.conf
echo "$(dirname "$path")"
/etc/nginx

The backtick equivalent needs \" around $path. Every escape is a place to make a mistake.

Why cd inside $() does not change your shell

Because $(...) forks a new process. The subshell gets a copy of your variables and a copy of your working directory. It changes its copy, prints something, and exits. The copy dies with it.

cd /tmp/subst-demo
pwd
target=$(cd /etc && pwd)
echo "$target"
pwd
/tmp/subst-demo
/etc
/tmp/subst-demo

Nothing failed. The cd worked, and pwd inside the subshell really did print /etc. There is simply no path for that change to travel back, because the only things a subshell hands to its parent are its standard output and an exit status.

Variable assignments behave the same way:

count=0
msg=$(count=99; echo "inside: $count")
echo "$msg"
echo "outside: $count"
inside: 99
outside: 0

The same rule explains the version of this bug that people hit far more often, which involves a pipeline rather than a substitution:

n=0
cat three.txt | while read -r line; do n=$((n+1)); done
echo "$n"
0

Every stage of a pipeline runs in its own subshell, so the while loop incremented a copy of n and then exited. Replace the pipe with a redirect and the loop runs in your shell:

n=0
while read -r line; do n=$((n+1)); done < three.txt
echo "$n"
3

Bash can run the last stage of a pipeline in the current shell with shopt -s lastpipe, but only when job control is off, which is never true in an interactive shell. Use the redirect.

Where did the prompt go? Interactive commands inside $()

A command substitution redirects standard output into a pipe and leaves standard input alone. A program that prints its question to standard output and then waits for an answer loses the question, not the waiting. The terminal looks frozen.

ask() { printf 'Username: '; read -r u; printf '%s\n' "$u"; }
ask
Username: deploy
deploy

The word deploy on the first line is what you typed. Now run the same function inside a substitution:

v=$(ask)
deploy

Only your own keystrokes appear, echoed by the terminal driver rather than by the program. The prompt went somewhere else:

echo "$v"
Username: deploy

The prompt is now inside the variable, glued to the answer, because $() captured everything the function wrote to standard output. Your typing still reached read, because standard input was never touched. That is the exact signature of the bug report that says "my script hangs and prints nothing".

Some tools write prompts to standard error or straight to /dev/tty so they survive this. Many do not. If a function must stay inside a substitution, send its prompt to standard error yourself:

ask() { printf 'Username: ' >&2; read -r u; printf '%s\n' "$u"; }
v=$(ask)
echo "$v"
Username: deploy
deploy

Standard error is not captured, so the prompt reaches your terminal and only the answer lands in v.

Why does ls look different inside $()?

Because ls calls isatty on file descriptor 1 and changes its output format based on the answer. At the prompt that descriptor is your terminal, so ls spreads names across the line in columns. Inside a substitution it is a pipe, so ls switches to one name per line.

mkdir -p /tmp/tty-demo
cd /tmp/tty-demo
touch alpha beta delta gamma
echo "$(ls)"
alpha
beta
delta
gamma

The same check turns off colour in grep --color=auto and turns off the pager in git. This is a feature. It means a script gets stable machine-readable output without having to ask for it.

This also answers a question people ask about pipelines. ls | sort at the prompt and $(ls | sort) print the same thing, because ls had a pipe on its output in both cases. What changes inside a substitution is the last stage of the pipeline. sort never checks for a terminal, so its output never changes. Put a terminal-aware command at the end of the pipeline and it does change, which is why a pipeline you tested by eye can behave differently the moment you wrap it in $().

One warning that follows from this: do not parse ls output in a script even though it looks convenient. Filenames may contain spaces and newlines. Use a glob, or find -print0 with read -d ''.

The trailing newlines that silently disappear

Command substitution removes every newline at the end of the output. Not the last one. All of them.

cd /tmp/subst-demo
printf 'hello\n\n\n' > blanks.txt
wc -c < blanks.txt
v=$(cat blanks.txt)
printf '%s' "$v" | wc -c
8
5

The file holds hello plus three newlines, so 8 bytes. The variable holds hello, so 5 bytes. Three bytes vanished with no warning.

The stripping is deliberate and usually helpful. It is what makes stamp=$(date -u +%Y%m%dT%H%M%SZ) produce a usable filename fragment instead of a name containing a line break, which is why the pattern is safe in something like a scheduled restic backup script:

stamp=$(date -u +%Y%m%dT%H%M%SZ)
printf 'backup-%s.tar.gz\n' "$stamp"
backup-20260804T031500Z.tar.gz

Your timestamp will differ. What matters is that the name is one line.

The cost is that you cannot use $() to move a file's exact bytes. If you need the trailing newlines, append a sentinel character inside the substitution and strip it afterwards:

v=$(cat blanks.txt; printf x)
v=${v%x}
printf '%s' "$v" | wc -c
8

The x sits after the newlines, so there are no trailing newlines left to strip. ${v%x} then removes the sentinel and leaves the original bytes.

Two related details. For reading a whole file, v=$(<blanks.txt) does the same work without running cat, because bash opens the file itself. It strips trailing newlines identically. And here-strings move in the opposite direction, adding a newline you did not write:

wc -c <<< 'abc'
4

Quote the result, or bash will split and glob it

An unquoted substitution goes through word splitting and then pathname expansion. A quoted one goes through neither.

printf 'a b\tc\nd\n' > words.txt
echo $(cat words.txt)
echo "$(cat words.txt)"
a b c d
a b	c
d

Unquoted, bash split the output on the characters in IFS, which by default are space, tab and newline, and echo then joined the four pieces with single spaces. Quoted, the text arrived as one word with the tab and the internal newline intact.

Globbing is the more dangerous half:

mkdir -p /tmp/glob-demo
cd /tmp/glob-demo
touch one.txt two.txt
printf '*\n' > pattern.txt
p=$(cat pattern.txt)
echo $p
echo "$p"
one.txt pattern.txt two.txt
*

The assignment itself was safe, because assignments do not word-split or glob. The damage happened at echo $p, where the * was expanded against the current directory. A script that reads a pattern from a config file and forgets the quotes will happily act on every file it can see. Quote every expansion and the whole class of bug goes away. Leave the quotes off only when you genuinely want the splitting, which is rare.

Why does local x=$(cmd) always return 0?

Because local is itself a command, and $? reports the status of local, not the status of the substitution it contained.

check_bad() { local out=$(false); echo "status: $?"; }
check_bad
status: 0

false exited 1, local succeeded at declaring the variable, and the 1 was discarded. declare, export, typeset and readonly all behave the same way. set -e will not catch it either, because from the shell's point of view nothing failed.

Split the declaration from the assignment:

check_good() { local out; out=$(false); echo "status: $?"; }
check_good
status: 1

A plain assignment at top level already reports the status of its last command substitution:

out=$(exit 3)
echo $?
3

This matters most in a health check running under a systemd service and timer, where a masked exit code means the unit reports success on every run while the work it was supposed to verify never happened.

The in-shell alternatives you actually want

Most people reach for $(...) because they want data in a variable. Often what they really want is input, not capture. These four forms keep the state in your current shell.

A redirect on the loop

cd /tmp/subst-demo
while read -r line; do printf 'got: %s\n' "$line"; done < three.txt
got: alpha
got: beta
got: gamma

No process is created for the input, so anything the loop body sets survives the loop.

Process substitution

while read -r line; do printf 'got: %s\n' "$line"; done < <(sort -r three.txt)
got: gamma
got: beta
got: alpha

<(command) hands you a path, something like /dev/fd/63, that reads the command's output. The command still runs in its own process. The while loop does not, which is the whole point. The space in < <( is required: <<( is read as the start of a here-document and will not parse. Process substitution is a bash feature, so a script with #!/bin/sh on Ubuntu or Debian runs under dash and will fail on it. Use #!/bin/bash.

Here-strings

read -r first rest <<< 'alpha beta gamma'
echo "$first"
echo "$rest"
alpha
beta gamma

<<< feeds one string to a command's standard input. read runs in your shell, so both variables are set where you can use them. read -r a b <<< "$(some-command)" is the normal way to pull two fields out of one line of output.

mapfile for whole files

mapfile -t lines < three.txt
echo "${#lines[@]}"
echo "${lines[1]}"
3
beta

mapfile, also spelled readarray, reads a file into an array in the current shell. -t removes the trailing newline from each element. It needs bash 4 or newer, and Ubuntu 24.04 ships bash 5.2, so it is available on any current server image.

A checklist before you commit the script

  • Write $(command), and quote it as "$(command)" unless you specifically want splitting.
  • Assume the trailing newlines are gone. Add a sentinel character if you need them back.
  • Keep interactive commands out of substitutions, or send their prompts to standard error.
  • Write local out on its own line when the exit status of out=$(command) matters.
  • To set variables from input, use a redirect or process substitution rather than a pipe.

These shapes turn up in the first small scripts people write when they are setting up a new VPS, and the failures stay quiet. A backup script that captured a prompt into a filename, or a health check that masked an exit code, keeps reporting success. The cost grows once you run the same script across several servers, because the output you never read is now output you never read on twenty machines.

FAQ

Why does cd inside $() not change my current directory?

$(...) runs its command in a subshell, which is a separate process holding a copy of your working directory and your variables. The cd changes that copy, then the process exits and the copy is discarded. A subshell can only return its standard output and an exit status, so there is no mechanism for the directory change to reach the parent shell. If you want the directory itself, capture it with target=$(cd /etc && pwd) and use "$target". If you want your shell to move, run cd directly, without a substitution around it.

What is the difference between $() and backticks in bash?

They produce the same result for simple commands, and differ in two ways that matter. $() nests directly, because the parser matches parentheses, while backticks need an escaped backtick for every level of nesting. Backticks also strip one layer of backslash escaping before the inner command is parsed, so ` echo 'a\\b' prints a\b while $(echo 'a\\b') prints a\\b. $() is in POSIX and works in dash and busybox sh`, so there is no portability argument for backticks.

Why does my script hang with no prompt when a command asks a question?

Command substitution redirects standard output into a pipe but leaves standard input connected to your terminal. A program that prints its prompt to standard output has that prompt captured into the variable, while the read behind it still waits for you. The terminal shows only the characters you type, echoed by the terminal driver. Move the question outside the substitution, or make the prompt write to standard error with printf 'Username: ' >&2 so it is not captured.

Why did the blank lines at the end of my variable disappear?

Command substitution removes every trailing newline, not just the last one. printf 'hello\n\n\n' > f; v=$(cat f) leaves v holding five bytes while the file holds eight. To keep them, append a sentinel inside the substitution and strip it afterwards with v=$(cat f; printf x) followed by v=${v%x}. The sentinel sits after the newlines, so there is nothing at the end for bash to remove.

Why does local out=$(cmd) always report success?

local is a command in its own right, and $? after that line reports whether local succeeded at declaring the variable. The exit status of the substitution is consumed and thrown away, which also means set -e will not stop the script. declare, export, typeset and readonly behave the same way. Write local out on one line and out=$(cmd) on the next, and $? then reports the real status.

#bash#shell-scripting#linux#subshell#coreutils