Variables, Quoting and Expansion
Storing values in variables, and the rules that make or break a script: quoting, word splitting and expansion in Bash, and strings, interpolation and here-strings in PowerShell.
Assigning variables
A variable is
a name for a value. The two shells write and treat them differently.
In Bash, a
shell variable
is assigned with name=value, without spaces around the
=, and read with $name. Its value is
always text: Bash calculates only where you ask for it.
server=web01
port=443
echo "Checking $server on port $port"
total="5"
total+=3
echo "String append: $total"
declare -i count=5
count+=3
echo "Integer add: $count"
echo "Arithmetic: $(( 5 + 3 ))"
Checking web01 on port 443 String append: 53 Integer add: 8 Arithmetic: 8
+= appends text to a normal variable, so 5 and 3 become
53. Only a variable declared as an integer (declare -i)
or arithmetic expansion $(( … )) adds numbers, and only
whole numbers.
PowerShell variables always carry the $, also when you
assign them, spaces around = are fine, and the value
keeps its .NET type. Mixing types follows one rule: the type of the
left operand wins.
$server = 'web01'
$port = 443
"Checking $server on port $port"
$port.GetType().Name
$server.GetType().Name
"5" + 5
5 + "5"
Checking web01 on port 443 Int32 String 55 10
"5" + 5 starts with a string, so the 5 is turned into
text and appended; 5 + "5" starts with a number, so the
string is converted and added. Values that come from files, CSV
exports or Read-Host are strings, so convert them
before you calculate: [int]$kwh. Variable names in
PowerShell are case-insensitive ($Server and
$server are the same variable); in Bash they are not.
Quoting in Bash
Quoting tells
Bash which characters to take literally. Single quotes take
everything literally; double quotes still expand
$variables, $(commands) and
$((arithmetic)), and a backslash inside them escapes
only $, ", \, the backtick
and a newline. $'…' (ANSI-C quoting) understands escape
sequences such as \t and \n.
echo 'Single: $HOME `date` \n'
echo "Double: \$HOME \"quoted\" \\"
printf '%s\n' $'Tab:\there'
Single: $HOME `date` \n Double: $HOME "quoted" \ Tab: here
Word splitting
The rule that breaks more Bash scripts than any other:
word splitting is the step in
which Bash cuts the result of an unquoted expansion, such as
$file or $(command), into separate words
at spaces, tabs and newlines (the characters in the special variable
IFS, the internal field separator). Each piece becomes
a separate argument. A file name with a space becomes two names:
file="meter readings.csv"
touch "$file"
ls $file
echo "---"
ls "$file"
ls: cannot access 'meter': No such file or directory ls: cannot access 'readings.csv': No such file or directory --- meter readings.csv
After splitting comes pathname expansion: any unquoted
*, ? or […] in the result is
treated as a
wildcard
and replaced by matching file names. An alert text with an asterisk
suddenly lists the folder:
touch billing.log portal.log
alert="Disk full on db01 *"
echo $alert
echo "$alert"
Disk full on db01 billing.log portal.log Disk full on db01 *
An empty unquoted variable is worse still: it disappears altogether,
and a test can then give the wrong answer without any error.
[ -n $answer ] becomes [ -n ], which is a
test of the non-empty string -n and therefore true:
answer=""
if [ -n $answer ]; then echo "unquoted: answer is not empty?!"; fi
if [ -n "$answer" ]; then echo "quoted: not empty"; else echo "quoted: empty"; fi
unquoted: answer is not empty?! quoted: empty
The fix is always the same: write "$var",
"$(command)" and "$@" in double quotes.
The few places that need no quotes, such as the right side of an
assignment and the inside of [[ … ]], are not worth
remembering as exceptions. ShellCheck, the Bash linter (see
Testing, Linting and Style), finds unquoted variables for you:
cat > cleanup.sh <<'X'
#!/bin/bash
file=$1
ls $file
X
shellcheck cleanup.sh || echo "shellcheck exit status: $?"
In cleanup.sh line 3: ls $file ^---^ SC2086 (info): Double quote to prevent globbing and word splitting. Did you mean: ls "$file" For more information: https://www.shellcheck.net/wiki/SC2086 -- Double quote to prevent globbing ... shellcheck exit status: 1
PowerShell has no word splitting: a variable is passed to a cmdlet
as one value, whatever it contains.
Get-ChildItem $file finds
meter readings.csv without quotes. Wildcards in the
value are still interpreted by cmdlets that accept them, which is
what -LiteralPath is for (see
Files, Paths and Globbing).
Parameter expansion
Parameter expansion is
Bash's ${…} syntax for reading a variable and changing
its value on the way out: taking part of it, replacing text,
changing case, or supplying a default. It runs inside the shell, so
it is faster and safer than piping a value through
sed or cut.
host="srv-web01"
file="/var/log/kestrel/billing.log"
echo "${host^^} ${#host} ${host:4} ${host/web/db}"
echo "${file##*/} ${file%/*} ${file%.log}.old ${file##*.}"
echo "Environment: ${KESTREL_ENV:-test}"
SRV-WEB01 9 web01 srv-db01 billing.log /var/log/kestrel /var/log/kestrel/billing.old log Environment: test
| Expansion | Result | PowerShell equivalent |
|---|---|---|
${#host} |
Length: 9 | $name.Length |
${host^^}, ${host,,} |
Upper case, lower case | $name.ToUpper(), .ToLower() |
${host:4} |
From position 4: web01 |
$name.Substring(4) |
${host/web/db} |
Replace the first match | $name.Replace('web', 'db') (all matches) |
${file##*/} |
Remove the longest match from the start: the file name | Split-Path $file -Leaf |
${file%/*} |
Remove the shortest match from the end: the folder | Split-Path $file -Parent |
${VAR:-default} |
The default if VAR is empty or unset |
$var ?? 'default' (when $null)
|
${VAR:?message} |
Stop with an error if VAR is empty or unset
|
[Parameter(Mandatory)] |
The PowerShell column assumes a variable $name with the
same text (not $host: $Host is a built-in
PowerShell variable). The ?? operator exists since
PowerShell 7.0. ${1:?message} is a compact way to make
an argument required:
cat > deploy.sh <<'X'
#!/bin/bash
target="${1:?usage: deploy.sh SERVER}"
echo "Deploying to $target"
X
bash deploy.sh web01
bash deploy.sh
echo "exit status: $?"
Deploying to web01 deploy.sh: line 2: 1: usage: deploy.sh SERVER exit status: 1
Command substitution and arithmetic
Command substitution, $(command), puts a command's output into a string or
variable. Two details matter in scripts: it removes
all trailing newlines, and $( ) nests cleanly,
where the old backtick form `command` does not.
Arithmetic expansion $(( … )) calculates with whole
numbers only.
x=$(printf 'web01\n\n\n')
echo "[$x]"
echo "Nested: $(basename "$(dirname /var/log/kestrel/billing.log)")"
[web01] Nested: kestrel
The quotes inside the nested $( ) are separate from the
outer ones, so each level is quoted on its own. PowerShell needs no
command substitution syntax: a command's output is a value, so
$files = Get-ChildItem just works, and inside a string
you use the subexpression operator, below.
Strings in PowerShell
PowerShell has the same two kinds of quotes. A single-quoted string
is literal. An
expandable string is a
double-quoted string in which PowerShell replaces variables by their
values when the line runs, a form of
string interpolation. The escape character is the backtick `, not the
backslash, and a quote inside a string of the same kind is doubled.
$server = 'srv-web01'
$kwh = 4521
'Single: Server $server'
"Double: Server $server"
"Property, wrong: $server.Length characters"
"Property, right: $($server.Length) characters"
"Cost: $($kwh * 0.28) EUR for $kwh kWh"
"Backtick: `$server is literal, a tab:`there"
'It''s fine'
"Say ""hi"""
Single: Server $server Double: Server srv-web01 Property, wrong: srv-web01.Length characters Property, right: 9 characters Cost: 1265.88 EUR for 4521 kWh Backtick: $server is literal, a tab: here It's fine Say "hi"
The subexpression operator
Line 5 of that example is the classic PowerShell string trap. In an
expandable string, only the plain variable name is replaced:
$server.Length becomes the value of
$server followed by the literal text
.Length. Anything more than a variable needs the
subexpression operator, $( ), which runs the code between the parentheses
and puts its result into the string: a property, a calculation, a
method call or a whole command. It looks like Bash's command
substitution and plays a similar role, but it runs PowerShell code,
not a separate program.
A few more rules complete the picture. An array inside a string is joined with spaces; a hash table shows only its type name, so reach in for the value:
$path = 'C:\Logs\app.log'
"Path: $path"
"Tab in PowerShell: a`tb"
"Backslash is literal: a\tb"
$arr = 1, 2, 3
"Array: $arr"
$h = @{ meter = 'M-1042' }
"Hash table: $h"
"Hash table value: $($h.meter)"
Path: C:\Logs\app.log Tab in PowerShell: a b Backslash is literal: a\tb Array: 1 2 3 Hash table: System.Collections.Hashtable Hash table value: M-1042
Because the backslash is an ordinary character, Windows paths need
no escaping in PowerShell strings. The flip side: in a regular
expression, such as with -replace, the backslash is the
regex escape character again, so '\\' matches one
backslash there (see
Text Processing in Scripts).
Here documents and here-strings
For text of several lines, such as an email body, a configuration
snippet or a ticket note, both shells have a block syntax. Bash has
the
here document: <<EOF expands variables in the block,
<<'EOF' with a quoted marker keeps it literal.
The short form <<< (a here string) feeds a
single string to a command's standard input.
ticket="INC-20419"
server="web01"
cat <<EOT
Ticket: $ticket
Server: $server
EOT
cat <<'EOT'
Literal: $server
EOT
grep -c web <<< "web01 web02"
Ticket: INC-20419 Server: web01 Literal: $server 1
PowerShell's here-string is a
multi-line string literal between @" and
"@ (expandable) or @' and
'@ (literal). Unlike a here document it is a value, not
input for a command, so you can store it in a variable. Inside it,
quotes need no escaping.
$server = 'web01'
$ticket = 'INC-20419'
$body = @"
Ticket: $ticket
Server: $server
Checked: $(Get-Date -Date '2026-10-06T09:00:00' -Format 'yyyy-MM-dd HH:mm')
"@
$body
$template = @'
Use $server and $(...) literally here.
'@
$template
Ticket: INC-20419 Server: web01 Checked: 2026-10-06 09:00 Use $server and $(...) literally here.
Undefined variables
A typo in a variable name is one of the most dangerous script bugs, because by default neither shell complains: the misspelled variable is simply empty. In a cleanup script, an empty path can turn a narrow command into a broad one. Both shells can be told to be strict.
cat > total.sh <<'X'
#!/bin/bash
kwh=4521
echo "Total: $kwhh kWh"
set -u
echo "Total: $kwhh kWh"
echo "never printed"
X
bash total.sh
echo "exit status: $?"
Total: kWh total.sh: line 5: kwhh: unbound variable exit status: 1
$kwh = 4521
"Total: $kwhh kWh"
Set-StrictMode -Version Latest
"Total: $kwhh kWh"
"Still running"
Total: kWh
InvalidOperation: /home/amara/scripts/example.ps1:4
Line |
4 | "Total: $kwhh kWh"
| ~~~~~
| The variable '$kwhh' cannot be retrieved because it has not been set.
Still running
Both now catch the typo, but they react differently. Bash's
set -u stops the script. PowerShell's strict mode
reports an error and, by default, carries on with the next line; to
stop, combine it with $ErrorActionPreference = 'Stop'.
Both are explained with their pitfalls on
Error Handling.
Side by side
| Task | Bash | PowerShell |
|---|---|---|
| Assign | server=web01 |
$server = 'web01' |
| Literal text | '$server' |
'$server' |
| Text with variables | "Host: $server" |
"Host: $server" |
| Expression in text | "$(( kwh * 2 ))", "$(cmd)" |
"$($kwh * 2)", "$($s.Length)"
|
| Escape character | \ |
` (backtick) |
| Multi-line text | <<EOF / <<'EOF' |
@" … "@ / @' … '@ |
| Default value | ${env_name:-test} |
$envName ?? 'test' |
| Catch undefined variables | set -u |
Set-StrictMode -Version Latest |
| Environment variable | $HOME, export NAME=value |
$env:HOME ($env:USERPROFILE on
Windows), $env:NAME = 'value'
|
Environment variables
are the one place where the shells meet: Bash reads them like any
other variable, PowerShell through the env: prefix.
They are also the right place for settings and secrets that must not
be written into a script (see
Writing Robust and Safe Scripts).