Comparing Numbers and Strings

When a Bash script needs to make a decision — is this number big enough, does this string match that value — it has to compare things. But Bash doesn’t have real number and string types the way languages like Python or JavaScript do; every variable is stored as plain text until you tell Bash how to interpret it. That distinction is the single most common source of subtle bugs in shell scripts, and understanding it is the key to writing conditionals that actually do what you think they do. This lesson covers every comparison operator Bash offers, how they differ, and the mistakes that trip up even experienced scripters.

Overview / How it works

Every comparison in Bash ultimately boils down to a command that finishes with an exit status: 0 means “true,” anything else means “false.” That single mechanic — the exit status stored in the special variable $? — is what if, while, and &&/|| all key off of. There is no separate boolean type; a comparison is just a command, and its truth is its success or failure.

Bash gives you three different tools for writing that comparison, and they behave differently:

test and its shorthand [ ... ] are a genuine command. Bash locates the command literally named [ (a builtin, though a standalone binary also exists), which requires its own closing ] as the final argument. Because it’s an ordinary command, its arguments go through normal word splitting and globbing before [ ever sees them — which is exactly why unquoted variables inside [ ] are dangerous.

[[ ... ]] is a Bash keyword, not a command. The shell parses everything between the brackets specially, before word splitting happens, so unquoted variables inside [[ ]] are far safer (though quoting is still good habit), and it adds extras like pattern matching and &&/|| directly inside the brackets.

(( ... )) is an arithmetic evaluation context. Everything inside is treated as a C-style numeric expression; variables that hold numeric strings are evaluated as numbers automatically (no $ needed), and the construct’s exit status is 0 (true) if the expression evaluates to non-zero, 1 (false) if it evaluates to zero.

Numeric comparisons inside test/[[ ]] use word operators — -eq, -ne, -lt, -gt, -le, -ge — because the symbols < and > are reserved for redirection in most shell contexts. Bash converts each operand to an integer for the comparison; if an operand isn’t a valid integer, Bash raises an “integer expression expected” error at runtime.

String comparisons use = (or ==, a Bash-only synonym recognized inside [[ ]]) and !=, which do a byte-for-byte comparison — no numeric interpretation happens at all, so "10" and "9" compare as text ("10" sorts before "9" lexicographically, since the character '1' sorts before '9'). Inside [[ ]] only, < and > perform lexicographic ordering based on the current locale’s collation.

Syntax

The general shape of a comparison depends on which construct you use:

[[ <value1> <operator> <value2> ]]
(( <expression> ))
[ <value1> <operator> <value2> ]

Numeric operators for [ ]/[[ ]], and their arithmetic-context equivalents:

Operator Meaning Equivalent in (( ))
-eq equal to ==
-ne not equal to !=
-gt greater than >
-lt less than <
-ge greater than or equal to >=
-le less than or equal to <=

String operators:

Operator Meaning Works in
= or == strings are equal [ ] and [[ ]] (== is [[ ]]-only)
!= strings are not equal [ ] and [[ ]]
< sorts before, lexicographically [[ ]] only (must be escaped as \< in [ ])
> sorts after, lexicographically [[ ]] only (must be escaped as \> in [ ])
-z string is empty (zero length) [ ] and [[ ]]
-n string is non-empty [ ] and [[ ]]

Examples

Example 1: Checking disk usage against a threshold (numeric)

This script reads the percentage of /var that’s in use and warns if it’s at or above 90%.

#!/usr/bin/env bash
set -euo pipefail

threshold=90
usage=$(df --output=pcent /var | tail -n 1 | tr -d '% ')

if [[ "$usage" -ge "$threshold" ]]; then
    echo "Warning: /var is at ${usage}% capacity"
else
    echo "/var usage is normal at ${usage}%"
fi

Output:

/var usage is normal at 42%

The command substitution captures the numeric percentage as text, but -ge forces both $usage and $threshold to be interpreted as integers, so 42 is correctly compared to 90 rather than compared letter by letter.

Example 2: Branching on an environment name (string)

A deployment script often needs to behave differently depending on a text value like an environment name.

#!/usr/bin/env bash
set -euo pipefail

environment="production"

if [[ "$environment" == "production" ]]; then
    echo "Deploying with production safeguards enabled"
elif [[ "$environment" == "staging" ]]; then
    echo "Deploying to staging"
else
    echo "Unknown environment: $environment"
fi

Output:

Deploying with production safeguards enabled

== inside [[ ]] is a plain string comparison — Bash never tries to interpret "production" as a number, it just checks whether the two strings are character-for-character identical.

Example 3: Counting retries with arithmetic comparison

(( ... )) is the natural choice when you’re already doing arithmetic, such as counting attempts in a retry loop.

#!/usr/bin/env bash
set -euo pipefail

max_attempts=3
attempt=1

while (( attempt <= max_attempts )); do
    echo "Attempt $attempt of $max_attempts"
    (( attempt++ ))
done

Output:

Attempt 1 of 3
Attempt 2 of 3
Attempt 3 of 3

Inside (( )), variable names don't need a leading $ — Bash knows attempt and max_attempts refer to variables in an arithmetic context, and <= works exactly as it would in C.

Example 4: Lexicographic string ordering

Inside [[ ]], < and > compare strings alphabetically, not numerically.

#!/usr/bin/env bash
set -euo pipefail

first="apple"
second="banana"

if [[ "$first" < "$second" ]]; then
    echo "$first comes before $second"
else
    echo "$first comes after $second"
fi

Output:

apple comes before banana

a sorts before b in the locale's collation order, so "apple" < "banana" is true. This only works safely inside [[ ]]; in [ ], unescaped < is redirection, as covered below.

How it works step by step

Take Example 1 apart to see what Bash actually does when that script runs:

  1. Bash spawns a subshell for the command substitution $(...). Inside it, three processes start and are connected by pipes: df --output=pcent /var writes a percentage column to its stdout, which the kernel feeds directly into tail -n 1's stdin to keep only the last line, whose output is fed into tr -d '% ''s stdin to strip the % sign and spaces. Each pipe connects one process's stdout file descriptor to the next process's stdin file descriptor — no temporary file is ever created.
  2. The subshell's final stdout (whatever tr printed) is captured as text by the parent shell and substituted into the command line, then assigned to usage.
  3. Bash reaches the [[ "$usage" -ge "$threshold" ]] keyword. Because it's a keyword rather than a command, Bash parses the whole expression itself: it takes the two operand strings, converts each to a machine integer, and compares them numerically.
  4. [[ ]] finishes with exit status 0 if the comparison is true, 1 if false. if reads that exit status directly — it never looks at any printed output, only whether the command "succeeded" or "failed."
  5. Based on that exit status, Bash runs either the then or else branch, and echo prints the message, interpolating ${usage} via parameter expansion.

Common Mistakes

Mistake 1: Using = instead of -eq for numbers

= always compares its operands as strings, even when they look like numbers. "5" and "05" are numerically equal but are different strings, so a string comparison says they don't match:

count=5
if [ "$count" = "05" ]; then
    echo "equal"
else
    echo "not equal"
fi

This prints not equal, which is surprising if a numeric comparison was intended. Use -eq instead, which converts both sides to integers first:

count=5
if [ "$count" -eq "05" ]; then
    echo "equal"
else
    echo "not equal"
fi

This correctly prints equal.

Mistake 2: Leaving a variable unquoted inside [ ... ]

If name is empty and you write [ $name = "admin" ], Bash expands $name to nothing before [ ever sees it, leaving [ = "admin" ] — a missing operand that fails with [: =: unary operator expected:

name=""
if [ $name = "admin" ]; then
    echo "welcome admin"
fi

Quoting the expansion keeps it as a single argument (even an empty one), so the comparison behaves correctly whether name is empty, contains spaces, or is unset:

#!/usr/bin/env bash
set -euo pipefail

name=""

if [ "$name" = "admin" ]; then
    echo "welcome admin"
else
    echo "access denied"
fi

Mistake 3: Using > or < inside [ ... ]

Inside a single [ ... ], > and < are not comparison operators at all — [ is an ordinary command, so the shell treats them as redirection before [ ever runs. This line silently creates (or empties) a file named 2.0 in the current directory and evaluates as true regardless, with no error printed:

version="1.5"
[ "$version" > "2.0" ]
echo "Comparison finished"

Switch to [[ ... ]], where > and < really are comparison operators and no file gets created:

#!/usr/bin/env bash
set -euo pipefail

version="1.5"

if [[ "$version" > "2.0" ]]; then
    echo "$version sorts after 2.0"
else
    echo "$version sorts before 2.0"
fi

One more to watch for: -eq, -lt, and similar operators expect both operands to look like integers. Running [[ "$count" -eq "abc" ]] fails at runtime with an "integer expression expected" error — always confirm a value actually came from something numeric before using a numeric operator on it.

Best Practices

  • Use [[ ... ]] for conditionals in Bash scripts — it avoids the quoting hazards of [ ... ] and adds pattern matching and safer word handling.
  • Use (( ... )) when both operands are genuinely numeric — it reads more naturally than -lt/-gt for arithmetic-heavy logic.
  • Always quote variable expansions inside [ ... ] (e.g. "$name") — quoting inside [[ ... ]] is safer by default but still good habit for consistency.
  • Use -z/-n to test for empty strings explicitly rather than comparing against "".
  • When branching on many possible string values, prefer a case statement over a long chain of [[ "$var" == ... ]] checks — it's clearer and supports pattern matching.
  • Never compare version-like strings ("1.10" vs "1.9") with string operators expecting numeric order — lexicographic comparison puts "1.10" before "1.9".
  • Add set -u near the top of scripts so an unset variable used in a comparison raises an error immediately instead of silently comparing against an empty string.

Practice Exercises

  1. Write a script called check-age.sh that sets age=17 and prints minor if age is less than 18, or adult otherwise, using -lt.
  2. Write a script that sets role="guest" and compares it against the string "admin", printing access denied unless they match exactly — be careful to quote both sides of the comparison.
  3. Write a script that uses (( )) to count from 1 to 5 in a while loop, printing each number, and stops as soon as the counter passes 5.

Summary

  • Bash has no native number/string types — the comparison operator you choose determines how the values are interpreted.
  • Numeric comparisons in [ ]/[[ ]] use word operators: -eq, -ne, -lt, -gt, -le, -ge.
  • (( ... )) lets you write numeric comparisons with familiar symbols: ==, !=, <, >, <=, >=.
  • String comparisons use =/== and !=; lexicographic ordering with </> only works safely inside [[ ]].
  • [[ ... ]] is safer than [ ... ] for Bash scripts because it isn't subject to word splitting and doesn't need escaped </>.
  • Always quote variable expansions in comparisons to avoid "unary operator expected" and word-splitting bugs.
  • Never mix up numeric and string operators — -eq on non-numeric input and = on numbers that should compare numerically are both common sources of bugs.