Migrate to RunxBuild and earn up to $50 in hosting credit on your first deposit.

Calculate your savings
unxBuild

Concatenating Strings in Bash: Four Ways and When Quoting Bites

Sean

Platform Writer

Aug 13, 2026
7 min read

Bash has no concatenation operator because it does not need one: writing two things next to each other joins them. full="$first$second" works, msg+=" more" appends in place, and printf -v out "%s: %s" "$k" "$v" builds a formatted string into a variable. Nearly every problem people hit is a missing brace in ${var} or a missing pair of quotes.

Concatenating Strings in Bash: Four Ways and When Quoting Bites

The syntax is unusually forgiving until it is not, and the failure mode is a silently empty variable rather than an error.

Table of contents

Adjacency, the basic form

first="Hello"
second="World"

greeting="$first $second"        # Hello World
joined="$first$second"           # HelloWorld
with_text="$first, $second!"     # Hello, World!

# Quotes are not required for a simple assignment...
plain=$first$second

# ...but they are required the moment a space is involved
spaced="$first $second"          # correct
# spaced=$first $second          # runs "World" as a command

That last line is the whole reason to quote by default. Unquoted, bash splits on whitespace and tries to execute the second word. Quote your variables; the exceptions are rare and deliberate.

The += operator

msg="Starting"
msg+=" deployment"
msg+=" of api"
echo "$msg"        # Starting deployment of api

# Building a string in a loop
list=""
for svc in api worker scheduler; do
    list+="$svc "
done
echo "$list"       # api worker scheduler

+= also works on arrays, where it appends elements rather than characters — worth knowing because the same operator does two quite different things depending on the variable’s type:

services=(api)
services+=(worker scheduler)
echo "${services[@]}"     # api worker scheduler
echo "${#services[@]}"    # 3

For accumulating a list you will iterate over later, the array is nearly always the better structure. A space-separated string re-splits badly the moment one element contains a space.

The brace problem

This is the failure that sends people searching. Bash reads a variable name as far as it can, so trailing text merges into the name.

name="deploy"

echo "$name_v2"      # empty -- looks for a variable called name_v2
echo "${name}_v2"    # deploy_v2

prefix="app"
echo "$prefix-api"   # app-api  -- fine, hyphen cannot be in a name
echo "$prefix.api"   # app.api  -- fine
echo "${prefix}_api" # app_api  -- braces required

Letters, digits, and underscores continue a variable name. Anything else terminates it. Since underscore is the one that catches people, the safe habit is to use ${var} whenever the variable is followed by any character at all.

Bash does not warn about an undefined variable by default — it expands to empty and carries on. set -u makes that an error, and it is worth having in any script that builds paths out of variables.

printf -v for formatted strings

When the string has structure, printf -v writes into a variable directly, with no subshell.

printf -v line "%-12s %s" "$service" "$status"
echo "$line"

# Zero-padded numbers
printf -v tag "v%03d" 7          # v007

# Repeat a character -- a neat trick
printf -v bar '%*s' 40 ''
echo "${bar// /-}"               # 40 dashes

The alternative, line=$(printf ...), forks a subshell and strips trailing newlines. -v avoids both. In a loop running thousands of times the difference is measurable; even when it is not, the intent is clearer.

Multi-line strings and heredocs

# Literal newlines survive inside quotes
block="line one
line two"

# $'...' interprets escapes
block=$'line one\nline two'

# Heredoc into a variable
read -r -d '' config <<'EOF'
server {
    listen 80;
    server_name example.com;
}
EOF

Quoting the heredoc delimiter as <<'EOF' prevents variable expansion inside — essential when the content contains $ characters you want kept literally, like nginx or awk snippets.

read -r -d '' returns a non-zero status at end of input, which trips set -e. Append || true if the script uses errexit.

Joining arrays

services=(api worker scheduler)

# Join with the first character of IFS
( IFS=,; echo "${services[*]}" )      # api,worker,scheduler

# Multi-character separator via substitution
printf -v joined '%s, ' "${services[@]}"
echo "${joined%, }"                    # api, worker, scheduler

${services[*]} joins using IFS; ${services[@]} keeps elements separate. Running the IFS change inside a subshell keeps the modification from leaking into the rest of the script, which is the kind of bug that shows up three functions later.

${joined%, } strips the trailing separator — % removes a suffix, # removes a prefix. These are worth learning; they replace a surprising amount of sed.

Building commands by concatenation: don’t

The one place string concatenation is genuinely the wrong tool is assembling a command to run.

# Fragile -- breaks on any path containing a space
cmd="rsync -av $src $dest"
$cmd

# Correct -- an array preserves argument boundaries
cmd=(rsync -av "$src" "$dest")
"${cmd[@]}"

The string version re-splits on whitespace when executed, so /var/my files/ becomes two arguments. The array version passes each element as one argument regardless of content. Use eval for this and you have added a code-execution path to any script that touches user input.

A note on where these scripts end up

String building in bash is usually in service of something else: a deploy script assembling a URL, an entrypoint composing a connection string, a CI step building a tag.

Those are exactly the places where an empty variable does the most damage, because it produces a plausible-looking wrong value rather than an error. set -euo pipefail at the top of every script converts most of them into a loud failure, which is worth more than any amount of careful quoting.

When the composed value is a database URL or an API key, the composition also stops being a scripting question and becomes a configuration one. Environment variables managed per service, injected at runtime rather than assembled in a script and echoed into a log, is the version that does not leak — which is how environment variables work for services on RunxBuild.

How this fits the rest of the stack

Adjacency concatenates, += appends, ${} disambiguates the variable name, and printf -v handles formatting without a subshell. Quote everything, use set -u so an undefined variable fails loudly, and build commands as arrays rather than strings.

When the string you are composing is a connection string or a token, the better answer is not composing it in the script at all. If you are working out what a service with managed environment variables and a database costs to run, the RunxBuild hosting calculator itemises each part separately.

Useful related references:

FAQ

How do I concatenate two strings in bash?

Write them next to each other: full="$a$b". Bash has no concatenation operator because adjacency already does it. Quote the assignment whenever the values might contain spaces, or the shell splits on whitespace and tries to run the second word as a command.

How do I append to a string in bash?

Use +=, as in msg+=" more text". On an array variable the same operator appends elements rather than characters, so arr+=(item) adds one element. For accumulating a list you will iterate over later, prefer the array.

Why is my variable empty when I concatenate with an underscore?

Because underscore is a valid character in a variable name, so $name_v2 looks for a variable called name_v2 rather than $name followed by _v2. Write ${name}_v2. Using braces whenever a variable is followed by any character avoids the whole class of problem.

What is the difference between printf -v and command substitution?

printf -v out "..." writes directly into a variable with no subshell and no stripping of trailing newlines. out=$(printf "...") forks a subshell and removes trailing newlines. -v is faster and preserves the string exactly, so prefer it in loops.

How do I join array elements with a separator in bash?

Set IFS in a subshell and expand with [*]: ( IFS=,; echo "${arr[*]}" ). For a multi-character separator, use printf -v joined '%s, ' "${arr[@]}" and strip the trailing separator with ${joined%, }. Changing IFS inside a subshell keeps the change from leaking.

#bash#string concatenation#shell scripting#variables#linux