Shell Arithmetic and Test Operators


  • Description: Integer arithmetic forms ($((...)), ((...)), let, expr), the three test commands (test, [, [[), and numeric / string / file operators
  • My Notion Note ID: K2A-E-4
  • Created: 2020-06-03
  • Updated: 2026-05-18
  • License: Reuse is very welcome. Please credit Yu Zhang and link back to the original on yuzhang.io

Table of Contents


1. Arithmetic Forms

Bash has four ways to do integer math; pick by context:

n=$(( 2 + 3 ))          # substitution: yields the value, assign or echo
echo $(( a * b ))

(( count++ ))           # evaluation: runs as a command, exit 0 if non-zero
(( a > b )) && echo big

let "x = 4 * 5"         # legacy "let" command
let x++

result=$(expr 2 + 3)    # external program — slow, requires spaces, `\*`
Form Use for Notes
$(( ... )) Get the value Preferred for assignment, echo, indexing
(( ... )) Conditional / mutation No $; exit status 0 if non-zero result. Good for loops, if (( ... ))
let "..." Same as (( )) Older form; same exit-on-zero gotcha with set -e
expr POSIX sh portability Slow (forks), needs spaces, must escape * as \*
  • Inside (( )) and $(( )): no $ needed for variables, no quoting, C-like syntax (++, --, ternary ?:, comma operator).
  • set -e trap: (( x = 0 )) returns exit 1 (because result is 0) → aborts under strict mode. Patch: (( x = 0 )) || true or assign via x=$(( 0 )).

2. Arithmetic Operators

Bash supports the full C operator set on 64-bit signed integers (no floats):

Op Meaning
+ - * / % Add / sub / mul / int-div / mod
** Exponent (bash extension)
++ -- Pre/post increment & decrement
+= -= *= /= %= Compound assign
<<= >>= &= ^= |= Compound bitwise
== != Equality
< <= > >= Order
&& || ! Logical
& | ^ ~ Bitwise AND/OR/XOR/NOT
<< >> Shift
? : Ternary
, Sequence
(( a = 10, b = 3 ))
echo $((a / b))            # 3   — truncating integer division
echo $((a % b))            # 1
echo $((1 << 10))          # 1024
echo $(( a > b ? a : b ))  # 10  — ternary
  • Bases: 0x hex, 0 octal, b#n arbitrary base 2..64.
    echo $((0xff))     # 255
    echo $((010))      # 8 — careful, leading 0 is OCTAL
    echo $((2#1010))   # 10 — explicit base 2
    
  • Pitfall: leading-zero strings parse as octal. (( n = 09 )) → "value too great for base" error. Strip with ${n#0} or use explicit base 10#$n.

3. Floats and bc / awk

Bash has no native float. Fork an external tool:

# bc — arbitrary precision; needs `scale` for division precision
echo "scale=4; 22/7" | bc            # 3.1428

# awk — fast for one-off math
awk 'BEGIN { print 22 / 7 }'         # 3.14286

# python — heavy but readable
python3 -c 'print(22/7)'             # 3.142857142857143
  • printf '%.3f\n' "$value" formats but doesn't compute.
  • For numerical work, drop into Python/awk/bc rather than fighting shell.

4. test, [, and [[ — Three Tests

test -f /etc/passwd && echo exists
[ -f /etc/passwd ] && echo exists
[[ -f /etc/passwd ]] && echo exists
Form Provided by POSIX? Word-splits unquoted $var? Pattern / regex?
test EXPR builtin / /usr/bin/test yes yes no
[ EXPR ] builtin / /usr/bin/[ yes yes no
[[ EXPR ]] bash keyword no no yes (globs + =~ regex)
  • [ ... ] is a command, not syntax. The brackets are arguments; spaces inside are required: [$a == $b] is wrong, [ "$a" = "$b" ] is right.
  • [[ ... ]] is a bash keyword — parses specially:
    • No word splitting → [[ -f $file ]] is safe even if $file is empty.
    • Supports &&, ||, ! inside.
    • RHS of == / != is a glob pattern: [[ $name == prod-* ]].
    • Supports =~ for regex (§9).
  • Rule of thumb: in bash scripts, use [[ ... ]]. Reach for [ ... ] only when targeting POSIX sh.

5. Numeric Comparison

Both [ ] and [[ ]] use the SAME operators here (different from the equality ops below):

Op Meaning
-eq equal
-ne not equal
-lt less than
-le less-or-equal
-gt greater than
-ge greater-or-equal
[[ $age -ge 18 ]] && echo adult
[ "$count" -lt 10 ] && echo "few"
  • -eq does numeric comparison: [[ 01 -eq 1 ]] is true. [[ 01 == 1 ]] is false (string).
  • Operands must look like integers; "abc" raises an error in [ ], a milder one in [[ ]].
  • Inside (( ... )), use the C-style operators instead: (( a > b )), (( count == 0 )).

6. String Comparison

Op (in [[ ]]) Meaning
= or == string equal (RHS is glob in [[ ]])
!= string not equal
< lexicographic less than (only inside [[ ]])
> lexicographic greater than (only inside [[ ]])
-z STR string is empty (zero length)
-n STR string is non-empty
[[ -z $name ]]   && echo "empty"
[[ -n $name ]]   && echo "set"
[[ $env == prod* ]]  && echo "prod-like"  # glob match!
[[ $a < $b ]]    && echo "a sorts first"
  • In [ ] (POSIX), < and > are NOT operators — they redirect. Use [[ ]] for ordering, or expr/sort for portable code.
  • In [ ], prefer = (POSIX) over == (bash-only inside [ ]).
  • The RHS of == inside [[ ]] is a glob, not a literal. Quote it to force literal match:
    [[ $version == 1.* ]]     # glob — matches 1.0, 1.2, ...
    [[ $version == "1.*" ]]   # literal — only the string "1.*"
    

7. File Tests

Used as [[ -X path ]]. Most common:

Test True if path...
-e exists (any kind)
-f exists and is a regular file
-d exists and is a directory
-L (or -h) is a symbolic link
-r is readable by this user
-w is writable by this user
-x is executable / dir traversable
-s exists and is non-empty (size > 0)
-p is a named pipe (FIFO)
-S is a socket
-b is a block device
-c is a character device
-O is owned by the current user
-G belongs to the current user's group
f1 -nt f2 f1 is newer than f2 (mtime)
f1 -ot f2 f1 is older than f2
f1 -ef f2 same inode (hardlink or same path)
[[ -d $dir ]] || mkdir -p "$dir"
[[ -s $log ]] && tail "$log"
[[ build/output.bin -nt src/main.c ]] || rebuild
  • -e follows symlinks. To check the link itself, use -L.
  • -f distinguishes "regular file" from "directory or device". Use it, not -e, when you mean "file".
  • Pitfall: unquoted [[ -f $path ]] is safe inside [[ ]] even if $path has spaces; inside [ -f $path ] it isn't — quote it.

8. Combining Tests

# Inside [[ ]] — preferred
[[ -f $f && -r $f ]] && cat "$f"
[[ $x -gt 0 && $x -lt 100 ]] || die "out of range"

# Outside, with shell operators — works everywhere
[ -f "$f" ] && [ -r "$f" ] && cat "$f"

# Inside [ ] with -a / -o — POSIX but FRAGILE
[ -f "$f" -a -r "$f" ] && cat "$f"
  • Prefer && and || between separate tests over -a / -o inside [ ]. The latter has unpredictable parsing with empty arguments and is deprecated by POSIX itself.
  • Inside [[ ]], && and || are full short-circuit operators, parentheses work for grouping: [[ ( $a == x || $a == y ) && $b == z ]].

9. Regex with [[ =~ ]]

Bash extension; ERE syntax (same as egrep / grep -E).

ip="192.168.1.10"
if [[ $ip =~ ^([0-9]{1,3}\.){3}[0-9]{1,3}$ ]]; then
  echo "looks like an IP"
fi

# Capture groups in BASH_REMATCH[]
re='^([^@]+)@(.+)$'
if [[ "[email protected]" =~ $re ]]; then
  echo "user=${BASH_REMATCH[1]}"     # alice
  echo "host=${BASH_REMATCH[2]}"     # example.com
fi
  • Pitfall: don't quote the regex literal — [[ $x =~ "[0-9]+" ]] matches the literal string [0-9]+, not "one-or-more digits". Either inline the regex unquoted, or store in a variable: re='[0-9]+'; [[ $x =~ $re ]].
  • Captures live in the indexed array $BASH_REMATCH. BASH_REMATCH[0] is the full match; [1], [2], ... are groups.
  • Locale matters: [A-Z] may include accented letters under non-C locales. Force with LC_ALL=C for byte-level regex.

10. References