Loosely Decoupled
TroubleshootingCode quality

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.

A small monkey with an ID badge sits between two identical gates with one empty box. At one gate the barrier stays down, at the other it is open and cyan, and he waves the box through.
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:

PowerShell
# 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:

PowerShell
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:

Text
$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:

Command
PS> $values = @()
PS> if ($values -ne $null) { 'has values' } else { 'nothing to add' }

Output

nothing to add
Command
PS> if ($null -ne $values) { 'has values' } else { 'nothing to add' }

Output

has values

The 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:

Add-ToAll.ps1
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 $null is false for an empty array, so there’s nothing to add and the rows come back unchanged.
  • $currentResult -ne $null is 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 farValues to addOriginal returnsFlipped 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:

Add-ToAll.Tests.ps1
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:

Command
PS> @(,@('X','A')) | ConvertTo-Json -Compress

Output

["X","A"]
Command
PS> @('X','A') | ConvertTo-Json -Compress

Output

["X","A"]
Command
PS> ConvertTo-Json -InputObject @(,@('X','A')) -Compress

Output

[["X","A"]]
Command
PS> ConvertTo-Json -InputObject @('X','A') -Compress

Output

["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-Json writes 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:

PowerShell
# 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
}
PowerShell
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:

PowerShell
[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

New articles, by email.

A short note when something new is published.