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.

A script ends with an exit code. The caller, which can be a shell, a scheduler or a CI pipeline, reads it: exit code 0 means success and the next step runs; a code from 1 to 255 means failure and the else branch, the || command or an alert runs. ./backup.sh ends with exit N caller reads N $? $LASTEXITCODE N = 0: success then && next step N = 1–255: failure else || alert
The exit code is the only thing a caller learns from a script without reading its output. Make it mean something.

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.
  • $LASTEXITCODE holds the numeric exit code of the last native program (such as bash, git or robocopy), or the number a PowerShell script passed to exit. 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.