Why Moving $null to the Left Side Broke My PowerShell Script
The analyzer’s fix is safe only where code meant a null check; this function relied on the array filter in ways only empty and nearly empty inputs reveal.

On this page
TL;DR
PSScriptAnalyzer’s PSPossibleIncorrectComparisonWithNull rule is right for
most code. When the left side of -ne is an array, PowerShell filters the array instead
of returning true or false. These two tests ask different questions:
# Does $items hold at least one item that is not null?
if ($items -ne $null) { 'has items' }
# Is the variable itself something other than null?
if ($null -ne $items) { 'not null' }An empty array fails the first test and passes the second. If the code you are
cleaning up relies on the first, state that meaning in a form the analyzer
accepts. It differs in one case: when the filter leaves exactly one item and
that item is falsy, such as 0, '', $false or an empty array. That case
is covered below:
function Test-HasItem {
param($Value)
@($Value | Where-Object { $null -ne $_ }).Count -gt 0
}
if (Test-HasItem $items) { 'has items' }I ran into this cleaning up old PowerShell that someone else wrote. The plan
was mechanical: run PSScriptAnalyzer and take its suggestions one at a time.
One of them came from the rule PSPossibleIncorrectComparisonWithNull:
$null should be on the left side of equality comparisons.I flipped the comparisons, and the warning went away. So did part of what the
function did. The original author had written $array -ne $null on purpose,
and the function depended on the behavior that the rule exists to warn about.
That’s why I wanted to write it up. Nothing here is a PowerShell bug, and the rule gives good advice for most code. This code was the exception: the author meant what they wrote, and fixing it broke it.
Reproduce the difference
In Windows PowerShell 5.1 or PowerShell 7, run:
PS> $values = @()
PS> if ($values -ne $null) { 'has values' } else { 'nothing to add' }Output
nothing to addPS> if ($null -ne $values) { 'has values' } else { 'nothing to add' }Output
has valuesThe same variable, compared with the same operator, gives opposite answers.
Why the two comparisons disagree
An equality or matching operator with a collection on its left filters the collection. It returns every item for which the comparison is true, as an array. about_Comparison_Operators (opens in a new tab) documents this for every equality and matching operator.
So @() -ne $null returns an empty array, and if converts an empty array to
$false. $null -ne @() has a scalar on the left, so it compares once and
returns $true, because an empty array isn’t $null.
That’s why the analyzer asks for $null on the left: most code that writes
$x -ne $null means “is $x null?”, and gets a filter instead when $x
happens to be an array. The rule’s own
documentation (opens in a new tab)
says the same. The rule is correct about that code. It can’t tell that
somebody’s code wanted the filter.
The filter has a second property, which matters later. if treats an array
that holds exactly one item as that item. @(0) -ne $null returns @(0), and
if (@(0)) is $false because 0 is.
What the original code meant
The function built combinations. Each call took the rows built so far and a set of values, and appended every value to every row. The two comparisons that the analyzer flagged are highlighted:
function Add-ToAll {
param(
[Parameter(Mandatory)] $currentResult,
[Parameter(Mandatory)] $valuesToAdd
)
$returnValue = @()
if ($valuesToAdd -ne $null) {
foreach ($value in $valuesToAdd) {
if ($currentResult -ne $null) {
foreach ($result in $currentResult) {
$newResult = @($result)
$newResult += ,$value
$returnValue += ,$newResult
}
}
else {
$returnValue += ,@(,$value)
}
}
}
else {
$returnValue = $currentResult
}
return ,$returnValue
}Both comparisons use the filter on purpose:
$valuesToAdd -ne $nullis false for an empty array, so there’s nothing to add and the rows come back unchanged.$currentResult -ne $nullis false for an empty array, so there are no rows yet and each value starts one.
The first call passes @() as $currentResult to start the chain. With the
operands flipped, $null -ne @() is true, the inner loop runs over nothing,
and the first call returns an empty array. Every later call appends to no rows
and also returns nothing.
Each version, run on the same inputs, with its result as JSON:
| Rows so far | Values to add | Original returns | Flipped returns |
|---|---|---|---|
@() | @() | [] | [] |
@() | @('A', 'B') | [["A"],["B"]] | [] |
@(@('X'), @('Y')) | @() | [["X"],["Y"]] | [] |
@(@('X'), @('Y')) | @($null) | [["X"],["Y"]] | [["X",null],["Y",null]] |
@(,@('X')) | 'A' | [["X","A"]] | [["X","A"]] |
@(@('X'), @('Y')) | @(0) | [["X"],["Y"]] | [["X",0],["Y",0]] |
Two rows still agree: both inputs empty, and the ordinary case, a real value added to rows that exist. A test suite built from typical inputs passes, and the change surfaces in production, on the first empty input.
Pin the current behavior in a Pester test
The analyzer’s fix looked safe because nothing checked what the function returned. Before you change inherited code, write a characterization test: run the current code on chosen inputs and assert what it returns now, whether or not that looks right. A refactor then has to keep every answer or change one on purpose.
This test for Pester 5, the PowerShell test framework, turns the earlier table
into cases. Save the function as Add-ToAll.ps1 in the same folder:
BeforeAll {
. $PSScriptRoot/Add-ToAll.ps1
}
Describe 'Add-ToAll' {
It '<Name>' -TestCases @(
@{ Name = 'both empty'; Json = '[]'
Rows = @(); Values = @() }
@{ Name = 'start from empty'; Json = '[["A"],["B"]]'
Rows = @(); Values = @('A', 'B') }
@{ Name = 'nothing to add'; Json = '[["X"],["Y"]]'
Rows = @(@('X'), @('Y')); Values = @() }
@{ Name = 'add null'; Json = '[["X"],["Y"]]'
Rows = @(@('X'), @('Y')); Values = @($null) }
@{ Name = 'one value'; Json = '[["X","A"]]'
Rows = @(,@('X')); Values = 'A' }
@{ Name = 'a single zero'; Json = '[["X"],["Y"]]'
Rows = @(@('X'), @('Y')); Values = @(0) }
) {
$result = Add-ToAll -currentResult $Rows -valuesToAdd $Values
ConvertTo-Json -InputObject $result -Compress | Should -Be $Json
}
}The cases that matter are the ones the old comparison treated specially: an empty array, an array of nulls and a single falsy value. They are where a flipped comparison changes the result, and they are the inputs a typical test suite leaves out.
Run Invoke-Pester ./Add-ToAll.Tests.ps1 before you touch the function. Every
case passes, because each expected value was copied from the original’s
output. Flip the two comparisons and four cases fail: start from empty,
nothing to add, add null and a single zero.
Note
Windows PowerShell 5.1 ships with Pester 3.4, which runs this file differently. Install Pester 5 first; the Pester installation guide (opens in a new tab) covers replacing the built-in version.
Each case compares JSON text instead of the array itself. Pass the result to
ConvertTo-Json with -InputObject, not through the pipeline:
PS> @(,@('X','A')) | ConvertTo-Json -CompressOutput
["X","A"]PS> @('X','A') | ConvertTo-Json -CompressOutput
["X","A"]PS> ConvertTo-Json -InputObject @(,@('X','A')) -CompressOutput
[["X","A"]]PS> ConvertTo-Json -InputObject @('X','A') -CompressOutput
["X","A"]That output is PowerShell 7. Windows PowerShell 5.1 prints the first line as
{"value":["X","A"],"Count":2}, which is one more reason to use
-InputObject.
The pipeline sends the items of an array, not the array, and that loses information in two ways:
- A result with one row loses a level of nesting. One row,
[["X","A"]], and a flat row,["X","A"], serialize to the same text, so a refactor that returned the wrong shape would pass. - An empty result sends nothing, so
ConvertTo-Jsonwrites nothing. With-InputObject, the same result is written as[].
When you write the cases, two inputs look different and are not:
@(@())is an empty array, not an array that holds one empty array. The array subexpression operator does not nest. Write,@()for the nested one.@(@('X'))is@('X'), for the same reason. Write@(,@('X')).
Make the intent explicit
Replace each filtering comparison with a test that says what it checks. Keep the function’s structure and change only the two conditions:
# True when $Value holds at least one item that is not null. An empty array
# and an array of nulls hold none, which is what `$Value -ne $null` tested.
function Test-HasItem {
param($Value)
@($Value | Where-Object { $null -ne $_ }).Count -gt 0
}if (Test-HasItem $valuesToAdd) {
foreach ($value in $valuesToAdd) {
if (Test-HasItem $currentResult) {PSScriptAnalyzer reports nothing for this version. Run the test again and
one case fails: a single zero. Given @(0), the explicit version returns
[["X",0],["Y",0]], and the original returned the rows unchanged.
The original treated @(0) as nothing to add, because an array with one item
converts to that item and 0 is false. The same is true of @(''),
@($false) and @(,@()), an array holding one empty array, and of any input
the filter reduces to one such item, such as @($null, 0). That is the one-item rule from earlier, not a decision anyone
made, and the explicit version drops it. If your data can contain a single
zero, empty string, $false or empty array, decide which behavior is correct. Change that
case’s expected value only if the new behavior is the one you want.
Common failures
The warning is on -eq, not -ne. The same filter applies.
@($null, $null) -eq $null returns two nulls, and an array with two items is
true in an if, even though both items are null.
The left side is never an array. Then flipping the comparison changes nothing, and it guards against the day it becomes one. Check where the value comes from: a cmdlet that returns one object returns it bare, and one that returns several returns an array.
You want to keep the old line and silence the rule. Suppress it on that function with a reason, so the next person reads why:
[Diagnostics.CodeAnalysis.SuppressMessageAttribute(
'PSPossibleIncorrectComparisonWithNull', '',
Justification = 'Empty array means nothing to add.')]The attribute goes above the function’s param() block. The explicit test is
still the better fix, because the reason then lives in the code rather than in
a comment about it.
Tested with
- Windows PowerShell
- 5.1 on Windows
- PowerShell
- 7 on macOS, 7.4.20 on Ubuntu 24.04
- PSScriptAnalyzer
- 1.23.0, 1.25.0
- Pester
- 5.9.1
- Date
- September 2026