Use != for strings and -ne for integers, always quote your variables, and prefer [[ ]] over [ ]. Nearly every bug in this area comes from an unquoted variable that turned out to be empty.
Comparison in Bash looks like other languages and behaves like a command-line argument parser, because that is what it is. [ is a program. Everything after it is a word, and the shell expands your variables into those words before the test ever runs.
Once that clicks, the failure modes stop being mysterious.
Table of contents
- Strings and integers use different operators
- The unquoted variable problem
- Use double brackets
- Empty, unset, and the difference between them
- Negation, and combining conditions
- Making scripts fail properly
- How this fits the rest of the stack
- FAQ
Strings and integers use different operators
This is the first thing to get right:
# Strings
if [ "$env" != "production" ]; then
echo "not production"
fi
# Integers
if [ "$count" -ne 0 ]; then
echo "count is not zero"
fi
The full pairs: = / != for strings, -eq / -ne / -lt / -le / -gt / -ge for integers.
Mixing them produces wrong answers rather than errors, which is what makes it dangerous:
[ "10" != "10.0" ] # true -- different strings
[ 10 -ne 10 ] # false -- equal numbers
[ "9" \> "10" ] # true -- string comparison: "9" sorts after "1"
[ 9 -gt 10 ] # false -- numeric comparison
That last pair is the classic version-comparison bug: comparing "9" and "10" as strings says 9 is larger. Use the numeric operators for anything numeric, always.
The unquoted variable problem
Here is the failure that produces the confusing error message:
name=""
if [ $name != "admin" ]; then # BROKEN
echo "not admin"
fi
# bash: [: !=: unary operator expected
$name expands to nothing, so [ receives two arguments — != and admin — instead of three. It is not a comparison with an empty string; the comparison no longer exists.
Same cause, worse symptom, when the value has a space:
name="John Smith"
[ $name != "admin" ] # [: too many arguments
The fix is quoting, every time, without exception:
[ "$name" != "admin" ] # correct, regardless of content
You may see the old defensive idiom [ "x$name" != "xadmin" ]. It predates reliable quoting advice and is no longer necessary. Just quote.
Use double brackets
[[ ]] is a Bash keyword rather than a command, so the shell parses it differently and the whole class of word-splitting problems disappears:
if [[ $name != "admin" ]]; then # safe even unquoted
echo "not admin"
fi
What else [[ ]] gives you:
&&and||inside the brackets, instead of-aand-o.- Pattern matching with
==and!=:[[ $file != *.log ]]. - Regex with
=~, and captures inBASH_REMATCH. <and>for string comparison without escaping.
One caveat worth knowing: quoting the right side of == or != inside [[ ]] turns off pattern matching. [[ $f != *.log ]] is a glob test; [[ $f != "*.log" ]] is a literal string comparison. That distinction is deliberate and occasionally surprising.
The only reason to use single [ is POSIX portability — a script that must run under dash or sh on a minimal system. If the shebang says #!/bin/bash, use [[ ]].
Empty, unset, and the difference between them
“Not equal to empty” is usually not the question you mean. Bash distinguishes unset from set-but-empty:
[ -z "$var" ] # true if empty (or unset)
[ -n "$var" ] # true if non-empty
[ -v var ] # true if SET, even if empty (bash 4.2+)
[[ -z ${var+x} ]] # true if unset -- distinguishes from empty
The distinction matters for configuration. An unset variable usually means “use the default”; an empty one often means someone explicitly set it to nothing, which is a different intent and sometimes an error.
Parameter expansion handles the common cases without a test at all:
port="${PORT:-8080}" # default if unset OR empty
port="${PORT-8080}" # default only if UNSET
: "${API_KEY:?API_KEY is required}" # exit with a message if unset or empty
That last form is the cleanest way to make a script fail fast on missing configuration, and it beats a hand-written check for every required variable.
Negation, and combining conditions
Two ways to express “not”, with a real difference:
if [[ $env != "production" ]]; then ... # negated operator
if ! [[ $env == "production" ]]; then ... # negated test
if ! grep -q ERROR app.log; then ... # negated command -- the important one
! before a command negates its exit status, which is how you check that something did not happen. That is a different tool from != and worth keeping distinct in your head.
For multiple conditions, and a mistake that appears constantly:
# WRONG -- always true. Any value differs from at least one of them.
if [[ $type != "a" || $type != "b" ]]; then ...
# RIGHT -- neither one
if [[ $type != "a" && $type != "b" ]]; then ...
# Clearer still for a set of values
case "$type" in
a|b) ;;
*) echo "unexpected type: $type" >&2; exit 1 ;;
esac
Negating an OR requires an AND. It reads wrong and is right, which is why case is often the more maintainable choice once there are more than two values.
Making scripts fail properly
Comparisons in deployment and health-check scripts are load-bearing, so it is worth the two lines that make failures visible:
#!/usr/bin/env bash
set -euo pipefail
: "${DEPLOY_ENV:?DEPLOY_ENV must be set}"
if [[ "$DEPLOY_ENV" != "production" && "$DEPLOY_ENV" != "staging" ]]; then
echo "unknown environment: $DEPLOY_ENV" >&2
exit 1
fi
set -u in particular turns a typo’d variable name from a silent empty string into an immediate error, which catches exactly the class of bug this post is about. And run shellcheck over anything that matters — it flags unquoted variables and the != || != mistake without being asked.
Scripts like this usually run in a build or deploy step. On RunxBuild, build commands run from your repository with the output in the build log, so a script that exits non-zero stops the deploy and tells you which line did it.
How this fits the rest of the stack
!= for strings, -ne for integers, quote every variable, and use [[ ]] unless you need POSIX portability. Remember that negating a set of values needs && rather than ||, and let set -euo pipefail plus shellcheck catch the rest. When these scripts run as build steps, the build log is where a failed comparison becomes visible — the RunxBuild hosting calculator covers what the service running them costs.
Useful related references:
- Bash Append to File:
>>,tee -a, and Heredoc - How to Run .sh File in Linux: bash, chmod, and shebang
- Bash For Loop 1 to 10: Four Ways, and When Each One Breaks
- Services on RunxBuild
FAQ
What is the not equal operator in Bash?
!= compares strings and -ne compares integers. They are not interchangeable: [ "10" != "10.0" ] is true because the strings differ, while [ 10 -ne 10 ] is false because the numbers are equal.
Why do I get ‘unary operator expected’ in Bash?
An unquoted variable expanded to nothing, so the test received two arguments instead of three. Quote it — [ "$var" != "value" ] — or use [[ ]], which does not word-split its operands.
What is the difference between [ ] and [[ ]] in Bash?
[ is a command whose arguments are subject to word splitting; [[ ]] is a shell keyword that is not. Double brackets also support &&, ||, glob patterns and =~ regex. Use [[ ]] in Bash scripts and [ ] only when POSIX portability is required.
How do I check if a variable is not empty in Bash?
Use [ -n "$var" ] for non-empty and [ -z "$var" ] for empty. To distinguish unset from set-but-empty, use [ -v var ] or [[ -z ${var+x} ]]. For defaults, parameter expansion such as ${PORT:-8080} avoids the test entirely.
Why is my condition with two not-equals always true?
Because you used || where you need &&. Any value differs from at least one of two options, so [[ $x != "a" || $x != "b" ]] is always true. Use && to mean neither, or a case statement for clarity.