Wes Ellis./ a personal notebook
Technology. Stories. Side projects.
A few things worth writing down.
← Back to Engineering

Engineering

The -WhatIf Trap Hiding in ForEach-Object

Part 1 of the thread PowerShell craft

THE SHORT VERSION4 points
  • ForEach-Object SkuId is really -MemberName, which supports ShouldProcess, so under -WhatIf it returns nothing.
  • My dry run swore a user had no licenses. They had two.
  • Use @($x).SkuId, Select-Object -ExpandProperty, or a script block instead. All three behave the same either way.
  • A dry run that prints the right actions can still make the wrong decisions. Check the data, not just the What if lines.

Here's a bug that'll get you exactly once, and it'll get you at the worst possible moment: during a dry run.

I was testing an offboarding script with -WhatIf. Every step printed its little "What if: Performing the operation…" line, just like it should. Then it got to licenses and said:

[Skipped] Remove licenses: No directly assigned licenses.

Except the test user had two. Run it for real and they'd come off. Run it with -WhatIf and the script swears there's nothing to do. That's backwards. A dry run is supposed to be the honest version.

What's going on

The culprit was this perfectly normal-looking line:

$skus = $user.LicenseAssignmentStates | ForEach-Object SkuId

That ForEach-Object SkuId shorthand is really ForEach-Object -MemberName SkuId. And -MemberName supports ShouldProcess, the same machinery behind -WhatIf.

So inside a function or script that has -WhatIf turned on, PowerShell treats reading a property as an action it should only pretend to do. It prints a "What if" line for each item and returns… nothing.

You can see it in about ten lines:

function Test-Trap {
    [CmdletBinding(SupportsShouldProcess)] param()
    $items = @([pscustomobject]@{ SkuId = 'a' }, [pscustomobject]@{ SkuId = 'b' })
    "ForEach-Object SkuId          -> $(@($items | ForEach-Object SkuId).Count)"
    "Member access (.SkuId)        -> $(@($items.SkuId).Count)"
    "Select-Object -ExpandProperty -> $(@($items | Select-Object -ExpandProperty SkuId).Count)"
}

Test-Trap          # 2, 2, 2
Test-Trap -WhatIf  # 0, 2, 2  <- there it is

In PowerShell 7.4, the second call gives you zero from ForEach-Object and two from everything else.

Form Without -WhatIf With -WhatIf
ForEach-Object SkuId 2 0
Member access (.SkuId) 2 2
Select-Object -ExpandProperty SkuId 2 2

Note

I haven't checked Windows PowerShell 5.1, so test before you trust it there.

The fix

Don't use the -MemberName shorthand for anything your script decides with. Either of these behaves the same with or without -WhatIf:

$skus = @($user.LicenseAssignmentStates).SkuId
$skus = $user.LicenseAssignmentStates | Select-Object -ExpandProperty SkuId

Tip

ForEach-Object { $_.SkuId } with a script block is fine too. It's only the property-name form that asks permission first.

The bigger lesson

A -WhatIf run that prints the right actions can still make the wrong decisions.

If your script branches on data ("are there any licenses?", "is this group dynamic?"), check that the data is actually there in the dry run, not just that the "What if" lines look tidy. I only caught this one because I was testing against fake data where I already knew the answer.