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:
- Bash spawns a subshell for the command substitution
$(...). Inside it, three processes start and are connected by pipes:df --output=pcent /varwrites a percentage column to its stdout, which the kernel feeds directly intotail -n 1's stdin to keep only the last line, whose output is fed intotr -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. - The subshell's final stdout (whatever
trprinted) is captured as text by the parent shell and substituted into the command line, then assigned tousage. - 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. [[ ]]finishes with exit status0if the comparison is true,1if false.ifreads that exit status directly — it never looks at any printed output, only whether the command "succeeded" or "failed."- Based on that exit status, Bash runs either the
thenorelsebranch, andechoprints 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/-gtfor 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/-nto test for empty strings explicitly rather than comparing against"". - When branching on many possible string values, prefer a
casestatement 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 -unear 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
- Write a script called
check-age.shthat setsage=17and printsminorifageis less than 18, oradultotherwise, using-lt. - Write a script that sets
role="guest"and compares it against the string"admin", printingaccess deniedunless they match exactly — be careful to quote both sides of the comparison. - Write a script that uses
(( ))to count from 1 to 5 in awhileloop, 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 —
-eqon non-numeric input and=on numbers that should compare numerically are both common sources of bugs.
