Conditions, Tests and Exit Codes
Making decisions in scripts: if, test and [[ ]] in Bash, comparison operators in PowerShell, exit codes and what success means to a shell.
What "true" means to each shell
Every script makes decisions: is the disk above 90%, does the log
contain errors, did the backup succeed? Both shells write that as
if, but they mean something different by "true".
Bash decides on commands. The part after
if is a command (or a list of commands). Bash runs it
and looks at its
exit status:
0 means success and counts as true, every other value counts as
false. That is why grep -q, test and even
your own scripts can be used directly as conditions.
printf '%s\n' '2026-10-06 09:00:01 INFO export started' \
'2026-10-06 09:00:07 ERROR meter M-1042 timeout' > app.log
if grep -q ERROR app.log; then
echo "app.log contains errors"
fi
if ! grep -q FATAL app.log; then
echo "app.log has no FATAL lines"
fi
true; echo "true ends with status $?"
false; echo "false ends with status $?"
app.log contains errors app.log has no FATAL lines true ends with status 0 false ends with status 1
PowerShell decides on values. The part between the
parentheses of if (…) is an expression. PowerShell
evaluates it and converts the result to a Boolean (True or False).
Comparison operators already return $true or
$false; other values are converted by a few simple
rules:
$log = Get-Content -Path ./app.log -ErrorAction SilentlyContinue
"[bool]0 -> $([bool]0)"
"[bool]42 -> $([bool]42)"
"[bool]'' -> $([bool]'')"
"[bool]'0' -> $([bool]'0')"
"[bool]'False' -> $([bool]'False')"
"[bool]`$null -> $([bool]$null)"
"[bool]@() -> $([bool]@())"
if (-not $log) { 'app.log is missing or empty' }
[bool]0 -> False [bool]42 -> True [bool]'' -> False [bool]'0' -> True [bool]'False' -> True [bool]$null -> False [bool]@() -> False app.log is missing or empty
Zero, an empty string, $null and an empty collection
are false; everything else is true, including the strings
'0' and 'False'. Text read from a file or
from Read-Host is a string, so convert it before you
test it: [int]'0' is false, '0' is true.
The rest of this page follows that split: first the tests that Bash runs as commands, then the operators PowerShell evaluates, then the exit codes that connect a script to whatever started it.
Tests in Bash: test, [ ] and [[ ]]
Because if needs a command, Bash has a command whose
only job is to compare things. The
test command, written
test or [ … ], checks a file, string or
number and exits with 0 (true) or 1 (false). It is a command, so it
needs spaces around the brackets and around every operator, and its
arguments go through the normal expansion and word splitting. The
file, string and number operators (-f, -z,
-eq, …) are listed in
Shell Scripting Basics › Test operators.
[[ ]] in Bash scripts
The double-bracket test,
[[ … ]], is Bash syntax rather than a command. Because
the shell parses it itself, variables inside are not split into
words and wildcards are not expanded against file names. On top of
that, == matches wildcard patterns, =~
matches a regular expression and fills the array
BASH_REMATCH with the captured groups, and
&& and || work inside the
brackets.
ticket="INC-20419"
if [[ $ticket == INC-* ]]; then
echo "[[ ]]: $ticket is an incident"
fi
if [ "$ticket" == "INC-*" ]; then
echo "[ ]: pattern matched"
else
echo "[ ]: compared with the literal text INC-*"
fi
if [[ $ticket =~ ^INC-([0-9]{5})$ ]]; then
echo "ticket number: ${BASH_REMATCH[1]}"
fi
[[ ]]: INC-20419 is an incident [ ]: compared with the literal text INC-* ticket number: 20419
The missing word splitting is what makes [[ ]] safer.
With single brackets, an empty unquoted variable disappears from the
command, and the test silently changes meaning:
owner=""
if [ -n $owner ]; then
echo '[ -n $owner ] says: the ticket has an owner (wrong)'
fi
if [ -n "$owner" ]; then
echo "has an owner"
else
echo '[ -n "$owner" ] says: no owner'
fi
if [[ -n $owner ]]; then
echo "has an owner"
else
echo '[[ -n $owner ]] says: no owner'
fi
[ -n $owner ] says: the ticket has an owner (wrong) [ -n "$owner" ] says: no owner [[ -n $owner ]] says: no owner
[ -n $owner ] became [ -n ]: a test with a
single argument, which is true whenever that argument is not empty
(and -n is not empty). Quote every variable inside
[ ], or use [[ ]]. Keep
[ ] for scripts that must run under a plain POSIX
sh (see
Cross-Platform Scripting).
Comparing numbers
Bash variables are text, so you must say when you mean numbers.
Inside [[ ]], < and
> compare strings alphabetically; -lt,
-gt and the other dash operators compare whole numbers.
The arithmetic command (( … )) is the most readable
choice: it uses the familiar
< > <= >= == != on numbers, and variables
inside need no $.
kwh=10
limit=9
[[ $kwh < $limit ]] && echo "[[ < ]] compares text: \"10\" sorts before \"9\""
[[ $kwh -gt $limit ]] && echo "[[ -gt ]] compares numbers: 10 is greater than 9"
(( kwh > limit )) && echo "(( > )) compares numbers: 10 is greater than 9"
[ $kwh < $limit ]
echo "[ < ] status: $?"
[[ < ]] compares text: "10" sorts before "9" [[ -gt ]] compares numbers: 10 is greater than 9 (( > )) compares numbers: 10 is greater than 9 example.sh: line 8: 9: No such file or directory [ < ] status: 1
The last test is a trap: inside single brackets, <
is input redirection, so Bash tries to read from a file called
9. (With > it would even create one.)
Arithmetic has one more trap that hits scripts working with dates
and meter numbers: a number with a leading zero is read as octal
(base 8), and 08 and 09 are not valid
octal numbers. Write 10# before the value to force base
10:
day="08" # for example from a file name like readings-2026-10-08.csv
echo "next day with 10#: $(( 10#$day + 1 ))"
next=$(( day + 1 ))
echo "next day without: [$next]"
next day with 10#: 9 example.sh: line 3: 08: value too great for base (error token is "08") next day without: []
The failed expansion aborted only the assignment: Bash printed an
error, left next empty and carried on with the next
line. A script that goes on to use such an empty value can do real
damage, which is one reason to check values before you calculate
with them. Bash arithmetic also knows only whole numbers; for a
value like 0.28 EUR per kWh, use awk or PowerShell.
PowerShell comparison operators
A
comparison operator
in PowerShell is a word that starts with a dash, such as
-eq or -like, and compares two values. It
returns $true or $false for a single value
on the left, and works as a filter when the left side is a
collection. PowerShell cannot use =,
> or < for comparisons: those
characters already mean assignment and redirection.
| Operator | True when | Example (True) |
|---|---|---|
-eq / -ne |
Equal / not equal | 'web01' -eq 'WEB01' |
-gt / -ge |
Greater than / greater than or equal | 91 -ge 90 |
-lt / -le |
Less than / less than or equal | 312 -lt 1000 |
-like / -notlike |
Matches a wildcard pattern (*, ?,
[ ])
|
'INC-20419' -like 'INC-*' |
-match / -notmatch |
Matches a regular expression | 'M-1042' -match '^M-\d{4}$' |
-contains / -notcontains |
The collection on the left holds the value on the right | 'web01','db01' -contains 'db01' |
-in / -notin |
The value on the left is in the collection on the right | 'db01' -in 'web01','db01' |
-is / -isnot |
The value is of a type | 42 -is [int] |
Case: ignored unless you ask
String comparisons in PowerShell ignore upper and lower case by
default, which suits Windows, where file names and user names are
case-insensitive too. Put a c after the dash
(-ceq, -clike, -cmatch) for a
case-sensitive comparison, or an i
(-ieq) to make the default explicit.
"-eq : $('KESTREL' -eq 'kestrel')"
"-ceq : $('KESTREL' -ceq 'kestrel')"
"-like : $('INC-20419' -like 'inc-*')"
"-clike : $('INC-20419' -clike 'inc-*')"
if ('INC-20419' -match '^INC-(\d{5})$') {
"-match : ticket number $($Matches[1])"
}
-eq : True -ceq : False -like : True -clike : False -match : ticket number 20419
Bash is the other way around: [[ ]] and
case are case-sensitive unless you set
shopt -s nocasematch. A successful
-match fills the automatic variable
$Matches, the PowerShell counterpart of
BASH_REMATCH (more in
Text Processing in Scripts).
The left operand decides the type
When the two sides have different types, PowerShell converts the right side to the type of the left side. The same comparison can therefore give different answers depending on the order:
"'10' -lt '9' : $('10' -lt '9') (both strings: compared as text)"
"10 -lt '9' : $(10 -lt '9') ('9' converted to the number 9)"
"'10' -lt 9 : $('10' -lt 9) (9 converted to the string '9')"
"[int]'10' -lt 9 : $([int]'10' -lt 9)"
'10' -lt '9' : True (both strings: compared as text)
10 -lt '9' : False ('9' converted to the number 9)
'10' -lt 9 : True (9 converted to the string '9')
[int]'10' -lt 9 : False
This matters as soon as data comes from text: values from
Import-Csv, Get-Content and
Read-Host are strings. Cast the value you test with
[int], [double] or
[datetime] first.
A collection on the left filters
With a collection (an array) on the left, -eq,
-like, -match and the other comparison
operators do not return True or False: they return the items that
match. -contains and -in are the operators
that answer "is it in there?".
$readings = 4521, 312, 9870, 312
$readings -eq 312
"matches: $(($readings -eq 312).Count)"
"contains 9870: $($readings -contains 9870)"
"9870 in list : $(9870 -in $readings)"
$empty = @()
"`$empty -eq `$null : $($empty -eq $null)"
"`$null -eq `$empty : $($null -eq $empty)"
312 312 matches: 2 contains 9870: True 9870 in list : True $empty -eq $null : $null -eq $empty : False
The last two lines show why the
PowerShell documentation
and code analyzers tell you to put $null on the
left: $empty -eq $null filters the empty array
and returns nothing, which prints as an empty value, while
$null -eq $empty asks the real question, "is this
variable null?", and answers False.
$usage = 91
if ($usage > 90) { 'disk almost full' }
"files now in the folder: $((Get-ChildItem -Name | Sort-Object) -join ', ')"
$status = 'Open'
if ($status = 'Closed') { "status is now: $status" }
files now in the folder: 90, example.ps1 status is now: Closed
$usage > 90 wrote 91 into a new file named
90 and produced no value, so the condition was false.
$status = 'Closed' changed the variable, and the
non-empty result counted as true. Use -gt and
-eq.
Branches: if, elif and elseif
With the tests in place, the branching itself looks almost the same
in both shells. Bash ends each condition with
; then and the whole block with fi, and
spells the middle branch elif; PowerShell uses curly
braces and elseif. A disk check with three levels,
first in Bash:
for usage in 72 91 97; do
if (( usage >= 95 )); then
echo "CRITICAL: disk at ${usage}%"
elif (( usage >= 90 )); then
echo "WARNING: disk at ${usage}%"
else
echo "OK: disk at ${usage}%"
fi
done
OK: disk at 72% WARNING: disk at 91% CRITICAL: disk at 97%
The same check in PowerShell:
foreach ($usage in 72, 91, 97) {
if ($usage -ge 95) {
"CRITICAL: disk at $usage%"
} elseif ($usage -ge 90) {
"WARNING: disk at $usage%"
} else {
"OK: disk at $usage%"
}
}
OK: disk at 72% WARNING: disk at 91% CRITICAL: disk at 97%
Order the branches from the most to the least specific: the first
condition that is true wins, and the others are skipped. To combine
conditions, Bash uses &&, || and
! (inside [[ ]] or between commands);
PowerShell uses -and, -or and
-not (or !). Both stop evaluating as soon
as the answer is known (short-circuit evaluation), so [[ -f $f && -s $f ]] and
(Test-Path $f) -and ((Get-Item $f).Length -gt 0) never
look at the size of a missing file.
Many cases: case and switch
When one value decides between several branches, Bash's
case (see
Shell Scripting Basics › case) and PowerShell's switch read better than a long
elif chain. Sorting Kestrel Energy ticket IDs by their
prefix:
for id in INC-20419 CHG-0815 REQ-1203 hello; do
case $id in
INC-*) echo "$id: incident" ;;
CHG-* | REQ-*) echo "$id: change or request" ;;
*) echo "$id: unknown ID format" ;;
esac
done
INC-20419: incident CHG-0815: change or request REQ-1203: change or request hello: unknown ID format
switch -Wildcard ('INC-20419', 'CHG-0815', 'REQ-1203', 'hello') {
'INC-*' { "${_}: incident"; continue }
{ $_ -like 'CHG-*' -or
$_ -like 'REQ-*' } { "${_}: change or request"; continue }
default { "${_}: unknown ID format" }
}
INC-20419: incident CHG-0815: change or request REQ-1203: change or request hello: unknown ID format
Three differences are worth knowing. PowerShell's
switch loops over an array by itself, with the current
item in $_. A condition can be a script block in
braces. And, unlike case, switch runs
every branch that matches, not just the first one; end a
branch with continue (next item) or break
(stop the switch) when one match is enough:
switch (91) {
{ $_ -ge 90 } { 'at least 90' }
{ $_ -ge 80 } { 'at least 80' }
}
'---'
switch -Regex ('ip=192.0.2.10', 'name=web01') {
'^ip=(.+)$' { "IP address: $($Matches[1])" }
'^name=(.+)$' { "host name : $($Matches[1])" }
}
at least 90 at least 80 --- IP address: 192.0.2.10 host name : web01
-Wildcard and -Regex change how the string
conditions are matched, and -CaseSensitive turns off
the default case-insensitivity. Without an option, a string
condition is compared with -eq.
Exit codes
Every program, command and script ends with an exit code (also called exit status): a number (on Linux and macOS from 0 to 255; Windows allows larger codes) that tells whoever started it how it went. 0 means success; anything else means a kind of failure, and the program's documentation says which. Exit codes are the main way scripts talk to each other, to a scheduler such as cron or Task Scheduler, and to a CI pipeline, which marks a step as failed when its script exits with a non-zero code.
Exit codes in Bash
$? holds the exit status of the command that just
finished. Some codes have a fixed meaning that comes from the shell
itself:
| Code | Meaning |
|---|---|
| 0 | Success |
| 1 |
General failure (for grep: nothing found)
|
| 2 |
Wrong usage or a real error (for grep: file
missing or unreadable)
|
| 124 | The time limit of timeout expired |
| 126 | Found, but not executable (permission denied) |
| 127 | Command not found |
| 128 + N | Ended by signal N: 130 after Ctrl+C (SIGINT, 2), 137 after SIGKILL (9), 143 after SIGTERM (15) |
printf 'INFO export finished\n' > app.log
printf 'echo "report"\n' > report.sh
grep ERROR app.log; echo "grep, no match: $?"
grep ERROR missing.log; echo "grep, missing file: $?"
./report.sh; echo "not executable: $?"
reprot.sh; echo "typo in the name: $?"
timeout 1 sleep 5; echo "timeout expired: $?"
check() { return 300; }
check; echo "return 300 gives: $?"
grep, no match: 1 grep: missing.log: No such file or directory grep, missing file: 2 example.sh: line 6: ./report.sh: Permission denied not executable: 126 example.sh: line 7: reprot.sh: command not found typo in the name: 127 timeout expired: 124 return 300 gives: 44
An exit code is a single byte, so values above 255 wrap around: 300 becomes 300 − 256 = 44. Stay between 0 and 125 for your own codes, so they never look like the shell's own 126, 127 or a signal.
$? is overwritten by every command, including
echo and [[ ]]. When the status is a plain
yes or no, test the command directly with if cmd or
if ! cmd (ShellCheck flags
if [ $? -ne 0 ] as SC2181). When a command has more
than two outcomes, save $? right away and branch on the
saved value:
printf 'INFO export finished\n' > app.log
for file in app.log missing.log; do
grep -q ERROR "$file" 2>/dev/null
status=$?
case $status in
0) echo "$file: errors found" ;;
1) echo "$file: no errors" ;;
*) echo "$file: could not check (grep status $status)" ;;
esac
done
app.log: no errors missing.log: could not check (grep status 2)
Your own script sets its exit code with exit N; without
it, the script ends with the status of its last command. Document
the codes at the top of the script, as
the log-summary script
does: 0 for OK, 1 for "problems found", 2 for wrong usage is a
common choice.
Exit codes in PowerShell
PowerShell keeps the result of the last command in an
automatic variable: a
variable that PowerShell creates and updates by itself, such as
$?, $_, $Matches,
$null and $true. You read automatic
variables but do not assign them. Two of them answer "did that
work?", and they answer different questions:
-
$?is a Boolean: True if the last command succeeded, False if it reported an error. It works for cmdlets and native programs alike. -
$LASTEXITCODEholds the numeric exit code of the last native program (such asbash,gitorrobocopy), or the number a PowerShell script passed toexit. Cmdlets do not change it.
bash -c 'exit 3'
"after bash : `$? = $?, `$LASTEXITCODE = $LASTEXITCODE"
Get-Item -Path ./readings.csv -ErrorAction SilentlyContinue
"after Get-Item : `$? = $?, `$LASTEXITCODE = $LASTEXITCODE"
bash -c 'exit 0'
"after bash : `$? = $?, `$LASTEXITCODE = $LASTEXITCODE"
after bash : $? = False, $LASTEXITCODE = 3 after Get-Item : $? = False, $LASTEXITCODE = 3 after bash : $? = True, $LASTEXITCODE = 0
The failed Get-Item set $? to False but
left $LASTEXITCODE at 3, the code of the last native
program. So after robocopy or git, check
$LASTEXITCODE, read it immediately, and remember that
some tools use non-zero codes for success too (robocopy
uses 0–7 for success and 8 and higher for failure).
A PowerShell script sets its own exit code with
exit N, as in Bash. Without it, the code depends on how
the script ended. When another program runs the script with
pwsh -File, an uncaught terminating error
(throw, or a cmdlet with
-ErrorAction Stop) gives exit code 1, but an ordinary
cmdlet error does not stop the script, and the script reports
success:
Set-Content -Path fails-quietly.ps1 -Value 'Get-Item -Path ./readings.csv'
Set-Content -Path fails-loudly.ps1 -Value 'Get-Item -Path ./readings.csv -ErrorAction Stop'
pwsh -NoProfile -File ./fails-quietly.ps1
"fails-quietly.ps1 exit code: $LASTEXITCODE"
pwsh -NoProfile -File ./fails-loudly.ps1
"fails-loudly.ps1 exit code: $LASTEXITCODE"
Get-Item: /home/amara/scripts/fails-quietly.ps1:1
Line |
1 | Get-Item -Path ./readings.csv
| ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
| Cannot find path '/home/amara/scripts/readings.csv' because it does not
| exist.
fails-quietly.ps1 exit code: 0
Get-Item: /home/amara/scripts/fails-loudly.ps1:1
Line |
1 | Get-Item -Path ./readings.csv -ErrorAction Stop
| ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
| Cannot find path '/home/amara/scripts/readings.csv' because it does not
| exist.
fails-loudly.ps1 exit code: 1
Short forms: &&, ||, ternary and ??
For one-line decisions, Bash has the
&& and || lists: run the next command only if the previous one succeeded or
failed. PowerShell 7 added the same pipeline chain operators, and
they look at $?, not at the value a command returns.
That difference catches people who come from Bash:
[[ -f app.log ]] && echo "log found" || echo "no log yet"
mkdir -p reports && echo "reports folder ready"
no log yet reports folder ready
bash -c 'exit 1' && 'bash succeeded' || 'bash failed'
Test-Path -Path ./app.log && 'log found' || 'no log yet'
$usage = 91
$level = $usage -ge 90 ? 'WARNING' : 'OK'
"ternary: $level"
$owner = $null
"null-coalescing: $($owner ?? 'unassigned')"
bash failed False log found ternary: WARNING null-coalescing: unassigned
Test-Path ran successfully and returned the
value False, so && carried on and printed "log
found". Use && and || in
PowerShell for native programs and for cmdlets that may fail, and an
if for values. The ternary operator
condition ? a : b and the
null-coalescing operator
?? are also new in PowerShell 7; they do not exist in
Windows PowerShell 5.1.
Side by side
| Task | Bash | PowerShell |
|---|---|---|
| Condition is… | A command; exit status 0 is true |
An expression; converted to $true/$false
|
| Equal (text) | [[ $a == "$b" ]] (case-sensitive) |
$a -eq $b (case-insensitive), -ceq
|
| Greater than (numbers) |
(( a > b )) or [[ $a -gt $b ]]
|
$a -gt $b |
| Wildcard match | [[ $id == INC-* ]] |
$id -like 'INC-*' |
| Regular expression |
[[ $id =~ ^INC-([0-9]+)$ ]],
BASH_REMATCH
|
$id -match '^INC-(\d+)$', $Matches
|
| File exists | [[ -f $path ]] |
Test-Path -Path $path -PathType Leaf |
| And / or / not |
&& || !
|
-and -or -not |
| Branches | if … elif … else … fi, case |
if … elseif … else, switch |
| Last result | $? (number 0–255) |
$? (True/False),
$LASTEXITCODE (native programs)
|
| End the script with a code | exit 2 |
exit 2 |
Loops repeat the decisions on this page for every server, file or line; see Loops. What happens when a command fails halfway through a script is the subject of Error Handling.