Bash variables look easy until a script quietly deletes the wrong folder because one of them was empty. Or until 08 + 1 throws an error that makes no sense. Or a file called my file.txt breaks a loop that worked fine all week.
This post walks through the pieces that cause most of those problems: assigning and protecting variables, command substitution, integer math, the ${var:-default} family, string tricks, validation, and quoting. Every example was run on Ubuntu 24.04 (Bash 5.3) and RHEL 9.6 (Bash 5.1), and the output matched on both. If you haven’t looked at how variables reach child programs yet, our Bash environment variables guide covers export and startup files.
Bash variables: assignment, naming, readonly and unset
Assigning is just name=value. No spaces around the equals sign. That’s the first thing everyone gets wrong:
name="Asif"
echo "Hello, $name"
greeting = "hi"
Hello, Asif
s1.sh: line 3: greeting: command not found
With spaces, Bash thinks greeting is a command and = and "hi" are its arguments. Naming rules are short: letters, digits and underscores, and the name can’t start with a digit. 2var=1 fails with the same “command not found” error. By habit, use UPPER_CASE for constants and exported values, lower_case for everything local to a script.
readonly: lock a value
When a value should never change halfway through a script, mark it readonly:
readonly API_URL="https://api.example.com"
API_URL="http://oops"
unset API_URL
s1.sh: line 6: API_URL: readonly variable
s1.sh: line 8: unset: API_URL: cannot unset: readonly variable
Both the reassignment and the unset are refused. There’s no way to undo readonly inside that shell, so use it on purpose.
unset: remove a variable completely
tmp=42
unset tmp
echo "tmp is now: [${tmp}]"
tmp is now: []
Hold on to that output. An unset variable and a variable set to an empty string look identical here, and we’ll pull them apart shortly.
Command substitution in Bash: use $( ), not backticks
$(command) runs a command and drops its output into your line. It nests cleanly, which backticks don’t:
today=$(date +%F)
echo "Today is $today"
echo "nested: $(basename "$(pwd)")"
Today is 2026-02-15
nested: tmp
One detail that surprises people: $( ) strips trailing newlines.
out=$(printf 'line1\nline2\n\n\n')
echo "[$out]"
[line1
line2]
Usually that’s what you want. The safety part is quoting the result when you use it, which we’ll get to in the quoting section. Unquoted $(ls) on a folder with a space in a file name gives you two words instead of one.
Bash arithmetic expansion: integer math and the leading zero trap
Bash does whole-number math inside $(( )). No decimals, ever:
echo "2 + 3 * 4 = $((2 + 3 * 4))"
2 + 3 * 4 = 14
echo "7 / 2 = $((7 / 2))"
7 / 2 = 3
echo "-7 / 2 = $((-7 / 2)) remainder: $((7 % 2))"
-7 / 2 = -3 remainder: 1
count=5; ((count++)); echo "count=$count"
count=6
Division throws away the fraction (and rounds toward zero for negatives). Need decimals? Hand it to bc: echo 'scale=2; 7/2' | bc prints 3.50.
Why 08 breaks and 010 equals 8
Inside arithmetic, a number with a leading zero is octal. So:
echo "010 = $((010))"
010 = 8
echo "0x1F = $((0x1F))"
0x1F = 31
month=08
echo "month+1 = $((month + 1))"
s3.sh: line 10: 08: value too great for base (error token is "08")
There’s no digit 8 in octal, so Bash gives up. This one bites hard with dates, since date +%m returns 08 and 09. And 07 + 1 works fine (it prints 8), which is why the bug hides until August. The fix is to force base 10:
echo "forced base 10: $((10#$month + 1))"
forced base 10: 9
Bash parameter expansion: default, assign, error and alternate values
Four small operators handle most “what if this variable isn’t there” situations. First, a default value that doesn’t touch the variable:
unset name
echo "default: ${name:-guest}"
echo "name still: [${name}]"
default: guest
name still: []
:= does the same but also stores the value:
echo "assign: ${name:=guest}"
echo "name now: [${name}]"
assign: guest
name now: [guest]
:+ works backwards. It gives you the alternate text only when the variable has a value, which is handy for optional flags:
debug=1
echo "alternate: ${debug:+--verbose}"
unset debug
echo "alternate when unset: [${debug:+--verbose}]"
alternate: --verbose
alternate when unset: []
And :? stops the script with an error message:
unset DB_HOST
echo "${DB_HOST:?DB_HOST must be set}"
s4.sh: line 12: DB_HOST: DB_HOST must be set
exit code: 1
That one line replaces a three-line if block, and it’s my favorite of the four.
Unset vs empty variables in Bash: what the colon operators do
Here’s the part most tutorials skip. The colon means “treat empty the same as unset”. Drop the colon and only a truly unset variable triggers the fallback. Three variables, two operators:
unset a; b=""; c="x"
for v in a b c; do
echo "== $v"
echo " \${$v-fallback} = [${!v-fallback}]"
echo " \${$v:-fallback} = [${!v:-fallback}]"
done
== a
${a-fallback} = [fallback]
${a:-fallback} = [fallback]
== b
${b-fallback} = []
${b:-fallback} = [fallback]
== c
${c-fallback} = [x]
${c:-fallback} = [x]
Look at b. Without the colon, an empty string is respected as a real value. With it, you get the fallback. To test for “is it set at all”, use [[ -v name ]]:
[[ -v a ]] && echo "a is set" || echo "a is unset"
[[ -v b ]] && echo "b is set (even though empty)" || echo "b is unset"
a is unset
b is set (even though empty)
Rule of thumb: use the colon forms when an empty value is as useless as a missing one (most config values). Skip the colon when empty is a legitimate choice.
Bash string operations: length, substrings, replacement and case
You don’t need sed or awk for the small stuff. Everything below is built into Bash:
file="backup-2026-02-15.tar.gz"
echo "length: ${#file}"
echo "chars 7-10: ${file:7:4}"
echo "last 6: ${file: -6}"
echo "replace one: ${file/-/_}"
echo "replace all: ${file//-/_}"
echo "drop .gz: ${file%.gz}"
echo "drop all ext: ${file%%.*}"
echo "drop prefix: ${file#backup-}"
length: 24
chars 7-10: 2026
last 6: tar.gz
replace one: backup_2026-02-15.tar.gz
replace all: backup_2026_10_12.tar.gz
drop .gz: backup-2026-02-15.tar
drop all ext: backup-2026-02-15
drop prefix: 2026-02-15.tar.gz
Offsets start at 0, and the space in ${file: -6} is required, otherwise Bash reads it as the :- default operator from the last section. For trimming, % cuts from the end and # cuts from the front. One symbol means shortest match, doubled means longest. Case conversion (Bash 4 and later) and path splitting round it out:
name="linux Pathfinder"
echo "upper: ${name^^}"
echo "lower: ${name,,}"
echo "cap 1st: ${name^}"
path=/var/log/nginx/error.log
echo "dir: ${path%/*} file: ${path##*/}"
upper: LINUX PATHFINDER
lower: linux pathfinder
cap 1st: Linux Pathfinder
dir: /var/log/nginx file: error.log
That last line is dirname and basename without launching two extra processes.
How to validate Bash variables before running a command
Check inputs first, then act. A short guard block at the top of a script saves you from the worst kind of bug, the one where an empty variable turns rm -rf "$dir/" into rm -rf /. Here’s a complete example:
#!/usr/bin/env bash
dir="${1:-}"
if [[ -z $dir ]]; then echo "error: no directory given" >&2; exit 1; fi
if [[ ! -d $dir ]]; then echo "error: '$dir' is not a directory" >&2; exit 2; fi
if ! command -v rsync >/dev/null 2>&1; then echo "error: rsync not installed" >&2; exit 3; fi
if ! [[ ${2:-} =~ ^[0-9]+$ ]]; then echo "error: days must be a whole number, got '${2:-}'" >&2; exit 4; fi
echo "all good: would back up $dir, keep $2 days"
Four checks: not empty (-z), is a real directory (-d), the tool exists (command -v), and the number really is a number (regex). Each failure gets its own exit code, so a cron job or CI pipeline can tell what went wrong:
./check.sh; echo "exit=$?"
error: no directory given
exit=1
./check.sh /nope 3; echo "exit=$?"
error: '/nope' is not a directory
exit=2
./check.sh /etc abc; echo "exit=$?"
error: days must be a whole number, got 'abc'
exit=4
./check.sh /etc 7; echo "exit=$?"
all good: would back up /etc, keep 7 days
exit=0
The ${1:-} part matters if you ever add set -u (which makes unset variables an error). It gives an empty default so your own friendly message prints instead of Bash’s generic one. If you’re building scripts like this, Basic Bash Commands for Scripting has the commands that go with them.
Bash quoting, word splitting and globbing explained
After Bash expands a variable, it does two more things to anything unquoted: it splits the result on whitespace (word splitting), then expands wildcard characters like * into file names (globbing). Quotes switch both off. Watch what happens with a file called report 1.txt:
f="report 1.txt"
ls -l $f
ls -l "$f"
ls: cannot access 'report': No such file or directory
ls: cannot access '1.txt': No such file or directory
-rw-rw-r-- 1 asif asif 0 Feb 15 06:34 report 1.txt
Unquoted, ls got two arguments. Quoted, it got one. Globbing works the same way when a pattern hides inside a variable:
pat="*.log"
echo unquoted: $pat
echo quoted: "$pat"
unquoted: a.log b.log
quoted: *.log
Now the dangerous version. Looping over $(ls) in a folder with two spaced file names:
for x in $(ls); do echo " would rm: <$x>"; done
would rm: <a.log>
would rm: <b.log>
would rm: <report>
would rm: <1.txt>
would rm: <report>
would rm: <2.txt>
Four files turned into six “files”, and two of them don’t exist. With a real rm in that loop, a name like my important dir could take out a different important directory. Loop over the glob instead (for x in *) and quote "$x". For lists of options, use an array rather than a string:
opts=(-l -h)
ls "${opts[@]}" a.log
-rw-rw-r-- 1 asif asif 0 Feb 15 06:34 a.log
Last piece, single versus double quotes. Single quotes keep everything literal. Double quotes still expand $ and $( ):
who=asif
echo 'single: $who $(date +%Y)'
echo "double: $who $(date +%Y)"
single: $who $(date +%Y)
double: asif 2026
A habit worth building: quote every $variable and every $(command) unless you specifically want splitting. It costs two characters and prevents a whole family of bugs. For more on what gets run where, see our first Bash script guide, and the report script post shows the ls-in-a-loop problem on a bigger example.
Frequently asked questions
What’s the difference between ${var:-default} and ${var:=default}?
:- returns the default for that one expansion and leaves the variable alone. := also assigns the default to the variable, so later lines see it.
How do I check if a Bash variable is set, even if it’s empty?
Use [[ -v name ]] (Bash 4.2 and newer) or ${name+x}. Both return true for an empty string and false only when the variable is unset.
Why does Bash say “value too great for base” for 08 or 09?
Numbers with a leading zero are read as octal inside $(( )), and 8 and 9 aren’t octal digits. Write $((10#$month)) to force base 10.
Should I use backticks or $( ) for command substitution?
$( ). It nests without escaping, it’s easier to read, and every modern shell supports it.
Do I always need double quotes around variables?
Almost always. The exceptions are rare: inside [[ ]] and in plain assignments like a=$b, where Bash doesn’t split anyway, and the cases where you really do want splitting. When in doubt, quote it.