Skip to main content
Bash · lesson 5 of 22
Bash beginner 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 portableYesNo (bash only)
Word splitting on unquoted varsYes (dangerous)No
Glob == / regex =~NoYes
&& and || insideNoYes

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

  1. Fix both broken [ ] lines in single-bracket.sh without 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
  2. Print whether each of 3 8 0 -4 is 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 % 2 is 0. With odd negatives the remainder is -1, so test == 0, never == 1.

  3. Split v2.14.3 into 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
  4. Add set -e above n=0; (( n++ )) and an echo after it. Does the echo run? Then change one character so it does.

    Solution

    With (( n++ )) the script exits silently with status 1 and the echo never runs. Switching to pre-increment fixes it, because ++n evaluates to the new value, 1:

    set -e
    n=0
    (( ++n ))
    echo "reached with n=$n"
    $ bash p5.sh
    reached with n=1

Frequently Asked Questions

What is the difference between [ ] and [[ ]]?
[[ ]] is a Bash built-in that is safer and more powerful. It supports pattern matching with ==, regex with =~, and doesn't require quoting variables to avoid word splitting. [ ] is POSIX and available in all shells but has more gotchas.
How do I do floating point math in Bash?
Use the bc utility: result=$(echo '3.14 * 2' | bc) or awk: result=$(awk 'BEGIN {print 3.14 * 2}'). Bash's $(( )) only handles integers.
Why does -eq work for numbers but not strings?
-eq is a numeric comparison operator. Use = or == for string comparisons inside [ ] or [[ ]].
Why does (( count++ )) make my set -e script exit?
(( )) returns exit status 1 when the expression evaluates to 0. count++ evaluates to the old value, so when count starts at 0 the command 'fails' and set -e stops the script. Use (( ++count )) or count=$((count + 1)) instead.