Bash $() vs Backticks: Which One Better?
Learn why cd and variables vanish inside Bash command substitution, why backticks nest badly, and when process substitution can keep shell state intact.
Wetin bash command substitution dey do
Bash command substitution dey replace $(command) with the text wey that command print go standard output. The older spelling wey use backticks dey do the same work. Everything wey dey surprise people come from two facts: the command dey run for separate process wey dem call subshell, and every newline for the end of the output dey get removed.
mkdir -p /tmp/subst-demo
cd /tmp/subst-demo
printf 'alpha\nbeta\ngamma\n' > three.txt
count=$(wc -l < three.txt)
echo "$count"3Na the whole feature be that. wc -l print 3 followed by newline, the newline get removed, and count hold the two characters wey you want. Notice say wc -l < three.txt dey print only number because GNU wc wey dey read standard input no get filename to print. Write wc -l three.txt instead, and you go capture 3 three.txt, wey be different string and common reason arithmetic fit fail later.
The rest of this guide na the behaviour wey people no dey expect, because subshell na separate process, and separate process no fit change the shell wey you dey type commands into.
Use $() and stop using backticks
Both forms dey valid. $() dey for POSIX, so dash, ash and busybox sh all support am. No portability reason remain to write backticks, and two clear reasons dey why you no suppose use dem.
Backticks no dey nest
echo "$(echo "$(echo hi)")"
echo "`echo `echo hi``"hi
echo hiThe second line print the literal words echo hi. The shell scan forward reach the next backtick wey no get escape, so the second backtick wey you type close the first one. The command wey e actually run na echo without arguments. E print empty line, then shell remove the newline, so nothing remain. The words echo hi remain as plain text, and the last pair of backticks run an empty command.
To nest backticks, you must escape every backtick inside:
echo "`echo \`echo hi\``"hiEvery extra level go double the escaping again. $() no need any of this, because parser match parentheses instead of scanning for delimiter character.
Backticks dey change your backslashes before command run
echo "$(echo 'a\\b')"
echo "`echo 'a\\b'`"a\\b
a\bThe same inner command produce different output. Inside backticks, shell remove one layer of backslash escaping before e parse the inner text, so the single quotes protect nothing. Inside $(), shell parse the text between the parentheses as ordinary script, so single quotes behave the way you expect. This problem dey worse for sed and awk one-liners, where one missing backslash fit turn working pattern into another pattern without any obvious error.
Quoting also start fresh inside $(). This mean say you fit nest double quotes without escaping dem:
path=/etc/nginx/nginx.conf
echo "$(dirname "$path")"/etc/nginxThe backtick equivalent need \" around $path. Every escape na another place wey mistake fit happen.
Wetin happen when you use cd inside $()
Because $(...) dey create another process. The subshell get copy of your variables and copy of your working directory. E change the copy, print something, then comot. The copy die together with am.
cd /tmp/subst-demo
pwd
target=$(cd /etc && pwd)
echo "$target"
pwd/tmp/subst-demo
/etc
/tmp/subst-demoNothing fail. The cd work, and pwd inside the subshell really print /etc. But no way for that change to go back, because the only things wey subshell fit give the parent na its standard output and exit status.
Variable assignments dey behave the same way:
count=0
msg=$(count=99; echo "inside: $count")
echo "$msg"
echo "outside: $count"inside: 99
outside: 0This same rule explain the version of this bug wey people dey encounter more often. That one involve pipeline instead of substitution:
n=0
cat three.txt | while read -r line; do n=$((n+1)); done
echo "$n"0Every stage of a pipeline dey run for its own subshell. So the while loop increase copy of n, then comot. Replace the pipe with redirect, and the loop go run for your shell:
n=0
while read -r line; do n=$((n+1)); done < three.txt
echo "$n"3Bash fit run the last stage of a pipeline for the current shell with shopt -s lastpipe, but na only when job control dey off. This one no dey happen for interactive shell. Use the redirect.
Prompt dey where? Interactive commands inside $()
Command substitution dey redirect standard output enter pipe, but e leave standard input as e be. Program wey print question go standard output, then wait for answer, go lose the question, but e no stop to wait. Terminal go look like say e freeze.
ask() { printf 'Username: '; read -r u; printf '%s\n' "$u"; }
askUsername: deploy
deployThe word deploy for the first line na wetin you type. Now run the same function inside substitution:
v=$(ask)deployNa only your own keystrokes dey show, and terminal driver echo dem instead of the program. The prompt don go somewhere else:
echo "$v"Username: deployThe prompt dey inside the variable now, join with the answer, because $() capture everything wey the function write go standard output. Your typing still reach read, because nothing touch standard input. Na this exact sign dey show for bug report wey talk say "my script hangs and prints nothing".
Some tools dey write prompts go standard error or straight go /dev/tty so dem fit survive this. Plenty no dey do am. If function must remain inside substitution, send the prompt go standard error yourself:
ask() { printf 'Username: ' >&2; read -r u; printf '%s\n' "$u"; }
v=$(ask)
echo "$v"Username: deploy
deployStandard error no dey captured, so the prompt reach your terminal, and na only the answer enter v.
Why ls dey look different inside $()?
Because ls dey call isatty for file descriptor 1 and change its output format based on the answer. For prompt, that descriptor na your terminal, so ls dey spread names across the line for columns. Inside substitution, na pipe e be, so ls switch to one name per line.
mkdir -p /tmp/tty-demo
cd /tmp/tty-demo
touch alpha beta delta gamma
echo "$(ls)"alpha
beta
delta
gammaThe same check dey turn off colour for grep --color=auto and turn off pager for git. This na feature. E mean say script go get stable machine-readable output without needing to ask for am.
This one still answer question wey people dey ask about pipelines. ls | sort for prompt and $(ls | sort) dey print the same thing, because ls get pipe for its output for both cases. Wetin change inside substitution na the last stage of the pipeline. sort no dey ever check for terminal, so its output no dey change. Put terminal-aware command for the end of the pipeline and e go change, na why pipeline wey you test by eye fit behave differently immediately you wrap am inside $().
One warning wey follow from this: no parse ls output for script even though e look convenient. Filenames fit contain spaces and newlines. Use glob, or find -print0 with read -d ''.
Newlines wey dey disappear quietly for the end
Command substitution dey remove every newline for the end of output. No be only the last one. Na all of dem.
cd /tmp/subst-demo
printf 'hello\n\n\n' > blanks.txt
wc -c < blanks.txt
v=$(cat blanks.txt)
printf '%s' "$v" | wc -c8
5The file get hello plus three newlines, so e be 8 bytes. The variable get hello, so e be 5 bytes. Three bytes disappear without any warning.
The stripping na deliberate, and e dey usually helpful. Na wetin make stamp=$(date -u +%Y%m%dT%H%M%SZ) produce filename fragment wey you fit use, instead of name wey get line break inside. Na why the pattern safe for something like scheduled restic backup script:
stamp=$(date -u +%Y%m%dT%H%M%SZ)
printf 'backup-%s.tar.gz\n' "$stamp"backup-20260804T031500Z.tar.gzYour timestamp go different. The important thing be say the name dey one line.
The disadvantage be say you no fit use $() move the exact bytes of file. If you need the newlines for the end, append sentinel character inside the substitution, then remove am afterwards:
v=$(cat blanks.txt; printf x)
v=${v%x}
printf '%s' "$v" | wc -c8The x dey after the newlines, so no trailing newlines remain to strip. ${v%x} come remove the sentinel and leave the original bytes.
Two related details. To read complete file, v=$(<blanks.txt) do the same work without running cat, because bash na im dey open the file by itself. E dey strip trailing newlines the same way. And here-strings dey move for the opposite direction: dem add newline wey you no write:
wc -c <<< 'abc'4Quote the result, or bash go split and glob am
Substitution wey no get quotes go pass through word splitting, then pathname expansion. The one wey get quotes no go pass through either one.
printf 'a b\tc\nd\n' > words.txt
echo $(cat words.txt)
echo "$(cat words.txt)"a b c d
a b c
dWhen quotes no dey, bash split the output for the characters wey dey IFS. By default, dem na space, tab, and newline. Then echo join the four pieces with single spaces. When quotes dey, the text arrive as one word, and the tab plus the newline wey dey inside remain intact.
Globbing na the more dangerous part:
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 safe, because assignments no dey do word splitting or globbing. The problem happen for echo $p, where * expand against the current directory. Script wey read pattern from config file and forget the quotes go act on every file wey e fit see. Quote every expansion, and this whole class of bug go disappear. Remove the quotes only when you truly want the splitting. That one rare.
Why local x=$(cmd) dey always return 0?
Because local na command by itself, and $? dey report the status of local, no be the status of the substitution wey e contain.
check_bad() { local out=$(false); echo "status: $?"; }
check_badstatus: 0false exit with 1, local succeed to declare the variable, and the 1 disappear. declare, export, typeset and readonly all behave the same way. set -e no go catch am too, because from shell point of view, nothing fail.
Separate the declaration from the assignment:
check_good() { local out; out=$(false); echo "status: $?"; }
check_goodstatus: 1Plain assignment for top level already report the status of its last command substitution:
out=$(exit 3)
echo $?3This matter pass for health check wey dey run under systemd service and timer, where masked exit code make the unit report success for every run, even though the work wey e suppose verify never happen.
Alternatives wey you actually want inside shell
Most people dey use $(...) because dem want data inside variable. But plenty times, wetin dem really want na input, no be capture. These four forms keep the state for your current shell.
Redirect for the loop
cd /tmp/subst-demo
while read -r line; do printf 'got: %s\n' "$line"; done < three.txtgot: alpha
got: beta
got: gammaNo process dey created for the input, so anything wey the loop body set go remain after 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) give you a path, something like /dev/fd/63, wey dey read the command output. The command still dey run for im own process. But while loop no dey run for separate process, and na that be the main point. The space for < <( dey required: <<( go read as the beginning of a here-document and e no go parse. Process substitution na bash feature, so script wey get #!/bin/sh for Ubuntu or Debian dey run with dash and e go fail there. Use #!/bin/bash.
Here-strings
read -r first rest <<< 'alpha beta gamma'
echo "$first"
echo "$rest"alpha
beta gamma<<< dey feed one string enter command standard input. read dey run for your shell, so both variables go set for where you fit use dem. read -r a b <<< "$(some-command)" na the normal way to pull two fields from one output line.
mapfile for complete files
mapfile -t lines < three.txt
echo "${#lines[@]}"
echo "${lines[1]}"3
betamapfile, wey dem also dey call readarray, dey read file enter array for the current shell. -t dey remove the newline wey dey at the end of each element. E need bash 4 or newer. Ubuntu 24.04 dey ship bash 5.2, so e dey available for any current server image.
Checklist wey you fit use before you commit the script
- Write
$(command), and quote am as"$(command)"unless you specifically want splitting. - Assume say the trailing newlines don disappear. Add a sentinel character if you need dem back.
- No put interactive commands inside substitutions, or send their prompts go standard error.
- Write
local outfor im own line when exit status ofout=$(command)matter. - To set variables from input, use redirect or process substitution instead of pipe.
These patterns dey show for the first small scripts wey people dey write when dem dey set up new VPS, and the failures dey happen quietly. Backup script wey capture prompt inside filename, or health check wey hide exit code, go continue report success. The cost go increase once you run the same script across several servers, because the output wey you no read before na now output wey you no read for twenty machines.
FAQ
Why cd inside $() no dey change my current directory?
$(...) dey run im command inside subshell, wey be separate process wey get copy of your working directory and variables. cd dey change that copy, then process dey exit and dem discard the copy. Subshell fit only return im standard output and exit status, so no way dey for the directory change to reach the parent shell. If na the directory itself you want, capture am with target=$(cd /etc && pwd) and use "$target". If you want your shell to move, run cd directly, without putting substitution around am.
Wetin be the difference between $() and backticks for bash?
Dem dey produce the same result for simple commands, but dem differ for two important ways. $() fit nest directly because parser dey match parentheses, while backticks need escaped backtick for every nesting level. Backticks also remove one layer of backslash escaping before parser parse the inner command, 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 backticks no get portability advantage.
Why my script dey hang without prompt when command ask question?
Command substitution dey redirect standard output into pipe, but e leave standard input connected to your terminal. Program wey print prompt to standard output go make that prompt enter the variable, while the read behind am still dey wait for you. Terminal go show only the characters wey you type, as terminal driver echo dem. Move the question outside the substitution, or make the prompt write to standard error with printf 'Username: ' >&2 so e no go enter the captured output.
Why the blank lines for the end of my variable disappear?
Command substitution dey remove every trailing newline, no be only the last one. printf 'hello\n\n\n' > f; v=$(cat f) leave v holding five bytes while the file hold eight. To keep dem, append sentinel inside the substitution and remove am afterwards with v=$(cat f; printf x) followed by v=${v%x}. The sentinel dey after the newlines, so nothing remain for bash to remove at the end.
Why local out=$(cmd) dey always report success?
local na command on im own, and $? after that line dey report whether local succeed to declare the variable. The substitution exit status get consumed and thrown away, and this mean say set -e no go stop the script. declare, export, typeset and readonly dey behave the same way. Write local out for one line and out=$(cmd) for the next line, then $? go report the real status.