Bash · lesson 5 of 22
Bash Operators
Learn arithmetic operators with $(( )), floating-point math with bc, string comparison, and the difference between test, [ ], and [[ ]].
By SkillByExample · Updated
Every example below is a complete script, run with bash 5.2 on Ubuntu 24.04.
Arithmetic with $(( ))
$(( )) evaluates C-style integer arithmetic and substitutes the result. (( )) without
the $ runs the same arithmetic for its side effect.
a=10
b=3
echo "sum=$(( a + b )) diff=$(( a - b )) prod=$(( a * b ))"
echo "div=$(( a / b )) mod=$(( a % b )) pow=$(( a ** b ))"
echo "neg div: $(( -7 / 2 )) neg mod: $(( -7 % 2 ))"
count=5
(( count++ ))
(( count += 3 ))
echo "count=$count"
echo "AND=$(( 6 & 3 )) OR=$(( 6 | 3 )) XOR=$(( 6 ^ 3 )) NOT=$(( ~6 )) SHL=$(( 1 << 3 ))"
$ bash arith.sh
sum=13 diff=7 prod=30
div=3 mod=1 pow=1000
neg div: -3 neg mod: -1
count=9
AND=2 OR=7 XOR=5 NOT=-7 SHL=8
Division truncates toward zero, so -7 / 2 is -3, not -4 as Python’s // would
give. The remainder takes the sign of the left operand. Inside (( )) you don’t need $
in front of variable names. let "a = 5 + 3" is an older spelling of the same thing; new
scripts should use (( )).
(( )) also sets an exit status, and this catches people out:
n=0
(( n++ ))
echo "status of (( n++ )) from 0: $? n is now $n"
(( n++ ))
echo "status of (( n++ )) from 1: $? n is now $n"
$ bash zero-status.sh
status of (( n++ )) from 0: 1 n is now 1
status of (( n++ )) from 1: 0 n is now 2
(( expr )) returns status 1 when the expression’s value is 0. Post-increment
evaluates to the old value, so the first n++ counts as a failure even though it worked.
With set -e enabled, that line kills the script (Practice task 4).
Leading Zeros Mean Octal
Numbers that come from date, file names or user input often have leading zeros, and
arithmetic treats a leading 0 as octal:
day=$(date -d 2026-10-08 +%d) # date pads the day to two digits
echo "day=$day"
echo "tomorrow: $(( day + 1 ))"
echo "this line still runs"
$ bash octal.sh; echo "exit $?"
day=08
octal.sh: line 3: 08: value too great for base (error token is "08")
this line still runs
exit 0
Octal digits only go up to 7, so 08 and 09 are errors. That’s the classic script that
works all year and breaks on the 8th and 9th of the month, and in August and September. The
failed echo printed nothing, but the script carried on and exited 0. Force base 10 with
10#, or ask date for no padding with %-d:
day=$(date -d 2026-10-08 +%d)
echo "10#: $(( 10#$day + 1 ))"
echo "%-d: $(( $(date -d 2026-10-08 +%-d) + 1 ))"
echo "010 is: $(( 010 )) 10#010 is: $(( 10#010 ))"
$ bash octal-fixed.sh
10#: 9
%-d: 9
010 is: 8 10#010 is: 10
The last line is the quieter form of the bug: 010 is valid octal, so it gives the wrong
answer, 8, with no error at all.
Floating Point with bc and awk
Anything with a decimal point has to go to an external tool.
echo "3.14 * 2" | bc
echo "scale=4; 22/7" | bc
echo "scale=10; 4*a(1)" | bc -l
total=250
done=175
echo "Progress: $(echo "scale=1; $done * 100 / $total" | bc)%"
width=10.5
height=4.2
awk -v w="$width" -v h="$height" 'BEGIN { printf "Area: %.2f\n", w * h }'
$ bash floats.sh
6.28
3.1428
3.1415926532
Progress: 70.0%
Area: 44.10
bc truncates to scale digits; it doesn’t round. It also truncates every
intermediate step, so 4*a(1) at scale=10 gives 3.1415926532 even though π is
3.14159265358…, which is off in the last digit. When you need correctly rounded output,
awk’s printf is usually the better tool. Pass shell values in with -v, not by pasting
them into the program text.
Comparison Operators
Numbers and strings use different operators. Mixing them up gives no error, just a different comparison.
a=10
b=9
[[ $a -gt $b ]] && echo "-gt: 10 is greater than 9"
(( a > b )) && echo "(( )): 10 is greater than 9"
[[ $a > $b ]] && echo "> : 10 sorts after 9" || echo "> : 10 sorts BEFORE 9 (text comparison)"
name=""
[[ -z "$name" ]] && echo "-z: name is empty"
name="Alice"
[[ -n "$name" ]] && echo "-n: name is non-empty"
$ bash compare.sh
-gt: 10 is greater than 9
(( )): 10 is greater than 9
> : 10 sorts BEFORE 9 (text comparison)
-z: name is empty
-n: name is non-empty
Inside [[ ]], > compares strings, character by character, so "10" sorts before
"9" because 1 sorts before 9. For numbers, use -eq -ne -lt -le -gt -ge in [[ ]],
or ordinary == != < <= > >= in (( )). -z tests for an empty string and -n for a
non-empty one.
Pattern Matching in [[ ]]
On the right-hand side of ==, [[ ]] matches glob patterns. =~ matches an extended
regular expression.
filename="report_2025.csv"
[[ "$filename" == *.csv ]] && echo "glob: is a CSV"
[[ "$filename" == "*.csv" ]] || echo "quoted: literal *.csv, no match"
if [[ "$filename" =~ ^report_([0-9]{4})\.csv$ ]]; then
echo "regex: year is ${BASH_REMATCH[1]}"
fi
$ bash patterns.sh
glob: is a CSV
quoted: literal *.csv, no match
regex: year is 2025
Quoting the pattern turns it into literal text, so "*.csv" only matches a file actually
named *.csv. Capture groups from =~ land in the BASH_REMATCH array.
test, [ ], and [[ ]]
test and [ are the same POSIX command. [[ is Bash syntax. The difference shows up as
soon as a variable is empty or contains a space.
role=""
[[ $role == admin ]] && echo "admin" || echo "[[ ]]: not admin"
[ $role == admin ] && echo "admin" || echo "[ ]: not admin"
file="my report.txt"
touch "$file"
[[ -f $file ]] && echo "[[ ]]: found it"
[ -f $file ] && echo "[ ]: found it"
$ bash single-bracket.sh
[[ ]]: not admin
single-bracket.sh: line 3: [: ==: unary operator expected
[ ]: not admin
[[ ]]: found it
single-bracket.sh: line 7: [: my: binary operator expected
[ is an ordinary command, so its unquoted arguments are word-split first. Empty $role
disappears, leaving [ == admin ], which is a syntax error. my report.txt becomes two
arguments. In the first case the || fallback hides the error by printing “not admin”
anyway. In the second, the file really exists, but the test errored and nothing printed.
[[ ]] doesn’t word-split, so both work.
| Feature | [ ] | [[ ]] |
|---|---|---|
| POSIX portable | Yes | No (bash only) |
| Word splitting on unquoted vars | Yes (dangerous) | No |
Glob == / regex =~ | No | Yes |
&& and || inside | No | Yes |
Use [[ ]] in Bash scripts. Use [ ] only in #!/bin/sh scripts, and quote everything
there.
Logical Operators
Inside [[ ]], &&, || and ! combine tests. Outside it, && and || chain commands
based on exit status.
age=25
role="admin"
if [[ $age -gt 18 && "$role" == "admin" ]]; then echo "Adult admin"; fi
if [[ "$role" == "admin" || "$role" == "superuser" ]]; then echo "Privileged user"; fi
[[ ! -f /tmp/lockfile ]] && echo "No lock file"
mkdir -p work && cd work && echo "now in $(basename "$PWD")"
rm missing.txt 2>/dev/null || echo "missing.txt was not there"
$ bash logical.sh
Adult admin
Privileged user
No lock file
now in work
missing.txt was not there
File Test Operators
File tests check a path before you act on it.
touch empty.txt
echo data > full.txt
mkdir dir
ln -s full.txt link.txt
for p in empty.txt full.txt dir link.txt nope; do
printf '%-10s' "$p"
[[ -e $p ]] && printf ' exists'
[[ -f $p ]] && printf ' file'
[[ -d $p ]] && printf ' dir'
[[ -L $p ]] && printf ' symlink'
[[ -s $p ]] && printf ' non-empty'
echo
done
$ bash files.sh
empty.txt exists file
full.txt exists file non-empty
dir exists dir non-empty
link.txt exists file symlink non-empty
nope
Two things are easy to miss. -f follows symlinks, so link.txt counts as a regular file
and a symlink. Only -L tells you it’s a link. -s means “size greater than zero”,
and a directory always has a non-zero size, so -s isn’t an “is this directory empty?”
test. The other common tests are -r, -w and -x: readable, writable and executable by
the current user.
Practice
-
Fix both broken
[ ]lines insingle-bracket.shwithout switching to[[ ]].Solution
Quote the expansions so empty or spaced values stay a single argument:
role="" [ "$role" == admin ] && echo "admin" || echo "[ ]: not admin" file="my report.txt" touch "$file" [ -f "$file" ] && echo "[ ]: found it"$ bash p1.sh [ ]: not admin [ ]: found it -
Print whether each of
3 8 0 -4is even or odd using(( )).Solution
for n in 3 8 0 -4; do if (( n % 2 == 0 )); then echo "$n even"; else echo "$n odd"; fi done$ bash p2.sh 3 odd 8 even 0 even -4 even-4 % 2is0. With odd negatives the remainder is-1, so test== 0, never== 1. -
Split
v2.14.3into major, minor and patch with=~, then check numerically that the minor version is at least 10.Solution
version="v2.14.3" if [[ $version =~ ^v([0-9]+)\.([0-9]+)\.([0-9]+)$ ]]; then echo "major=${BASH_REMATCH[1]} minor=${BASH_REMATCH[2]} patch=${BASH_REMATCH[3]}" (( BASH_REMATCH[2] >= 10 )) && echo "minor is two digits" fi$ bash p3.sh major=2 minor=14 patch=3 minor is two digits -
Add
set -eaboven=0; (( n++ ))and anechoafter it. Does theechorun? Then change one character so it does.Solution
With
(( n++ ))the script exits silently with status 1 and theechonever runs. Switching to pre-increment fixes it, because++nevaluates to the new value,1:set -e n=0 (( ++n )) echo "reached with n=$n"$ bash p5.sh reached with n=1