Behind the PowerShell Pipeline logo

Behind the PowerShell Pipeline

Subscribe
Archives
August 2, 2022

More Common Variables That Aren't Common

In a recent article, I began exploring a few common parameters. These are parameters that every cmdlet includes, although I suspect their usage is anything but common. This is unfortunate because incorporating these parameters can enable very creative pipelined expressions or add depth to your PowerShell scripts. In the previous article in this series, I demonstrated how to use the common OutVariable parameter. While this can be useful, the parameter has limitations. Fortunately, some of those limitations can be mitigated using another common parameter, PipelineVariable. This parameter can be tricky to wrap your head around so let’s dig into it.

Pipeline Execution

The OutVariable and PipelineVariable parameters are designed based on pipeline execution. As I demonstrated in the last article, the variable you specify with the OutVariable parameter isn’t finalized until the associated command completes. This is a critical point. Except for a few exceptions, when you run a pipelined expression, PowerShell isn’t waiting for each command to complete.

CommandA -outvariable a | CommandB -ob b | CommandC | CommandD 

CommandA immediately begins piping to CommandB, which immediately begins piping to CommandC. In other words, the first three commands could be running simultaneously, passing objects down the pipeline. This is a topic I’ll have to dive into further in a future article.PS

This means that $a isn’t finalized until CommandA completes. The implication is that you can’t expect to use the variable later in the same pipelined expression. Here’s an example that tries to do just that but fails to give the desired result.

PS C:\> Get-CimInstance -ClassName Win32_PhysicalMemory -ComputerName dom1, srv1, srv2 -OutVariable v |
Measure-Object -Property capacity -Sum |
Select-Object @{Name = "Computername"; Expression = { $v.pscomputername.toupper() } },
@{Name = "MemoryGB"; Expression = { $_.sum / 1GB } }


Computername       MemoryGB
------------       --------
{DOM1, SRV1, SRV2}        6

The variable $V contains the results of the Get-CimInstance command. This command is trying to use the PSComputername property of each object in $V.

The command is structured this way because the Win32_PhysicalMemory class will return an instance for each memory bank, and a computer may have more than one. Of course, there are other ways to structure the command, including breaking it up into several steps instead of a single pipelined expression.

PipelineVariable

This leads us to the PipelineVariable parameter, which has an alias of pv. You set this parameter per command, and you can use the variable later in your pipeline expression while it is running.

However, you can’t simply replace one common parameter with the other.

PS C:\> Get-CimInstance -ClassName Win32_PhysicalMemory -ComputerName dom1, srv1, srv2 -PipelineVariable v |
Measure-Object -Property capacity -Sum |
Select-Object @{Name = "Computername"; Expression = { $v.pscomputername.toupper() } },
@{Name = "MemoryGB"; Expression = { $_.sum / 1GB } }


Computername MemoryGB
------------ --------
SRV2                6

When using the PipelineVariable parameter, you typically need to structure your expression to use ForEach-Object.

PS C:\> Get-CimInstance -ClassName Win32_PhysicalMemory -ComputerName dom1, srv1, srv2 -PipelineVariable v |
ForEach-Object {
    $_ | Measure-Object -Property capacity -Sum |
    Select-Object @{Name = "Computername"; Expression = { $v.pscomputername.toupper() } },
    @{Name = "MemoryGB"; Expression = { $_.sum / 1GB } }
}


Computername MemoryGB
------------ --------
DOM1                2
SRV1                2
SRV2                2

This is an excellent example of the importance of visualization and understanding what is happening in the pipeline. Each CimInstance is getting piped to ForEach-Object which processes each instance separately. The $_ getting piped to Measure-Object is the CimInstance. At the same time, each CimInstance is also getting saved to the pipeline variable $v.

The Measure-Object command, which knows nothing about the CimInstance, is piped to Select-Object. Here I can use the pipeline variable and reference the PSComputername property. Remember, $v is each CimInstance as it is processed in the pipeline. The $_ in the MemoryGB expression is the measurement object.

I have to point out that this example is a bit artificial as each computer only has a single memory bank. In fact, I expect you’ll often find you won’t need to use this common parameter. Here’s a revised version of my code with my laptop, which has two memory banks.

PS C:\> Get-CimInstance -ClassName Win32_PhysicalMemory -ComputerName dom1, srv1, srv2, $env:computername | Group-Object -property PSComputername | Select-Object @{Name = "Computername"; Expression = { $_.name.toUpper()}},@{Name = "MemoryGB"; Expression = { ($_.group | Measure-Object -Property capacity -Sum).sum / 1GB } },     
@{Name= "MemoryBanks";Expression = {$_.count}}


Computername MemoryGB MemoryBanks
------------ -------- -----------
DOM1                2           1
SRV1                2           1
SRV2                2           1
THINKX1-JH         32           2

Combining Common Parameters

That isn’t to say there isn’t a use case. Here’s code that you might have in a script to report the size and number of files in a top-level folder.

$path = 'c:\work'
Get-ChildItem -Path $Path -Directory -OutVariable d |
ForEach-Object -Begin { $data = @() } -Process {
    Write-Host "Processing $($d.fullname)" -ForegroundColor green
    $data += Get-ChildItem -Path $d.fullname -File -Recurse | Measure-Object -Property length -Sum -ov m |
    Select-Object @{Name = "Path"; Expression = { $d.Fullname } },
    @{Name = "LastModified"; Expression = { $d.lastWriteTime } },
    @{Name = "Name"; Expression = { $d.Name } },
    @{Name = "Files"; Expression = { $m.count } },
    @{Name = "SizeKB"; Expression = { [math]::Round($m.sum / 1kb, 2) } }
} -End {
    Write-Host "Processed $($d.count) top level folders in $Path" -ForegroundColor Cyan
    $data
}

Read through the code and try to visualize what it is doing. Notice that I am storing the top-level directories using OutVariable. Measure-Object is storing its results in variable $m. But here’s what happens.

I am using Write-Host to illustrate how the value of $d is changing. Now, look at this version which uses a combination of OutVariable and PipelineVariable.

$path = 'c:\work'
Get-ChildItem -Path $Path -Directory -PipelineVariable d -OutVariable dirs | ForEach-Object -Begin { $data = @() } -Process {    
Write-Host "Processing $($d.fullname)" -ForegroundColor green
    $data += Get-ChildItem -Path $d.fullname -File -Recurse | Measure-Object -Property length -Sum -pv m |
    Select-Object @{Name = "Path"; Expression = { $d.Fullname } },
    @{Name = "LastModified"; Expression = { $d.lastWriteTime } },
    @{Name = "Name"; Expression = { $d.Name } },
    @{Name = "Files"; Expression = { $m.count } },
    @{Name = "SizeKB"; Expression = { [math]::Round($m.sum / 1kb, 2) } }
} -End {
    Write-Host "Processed $($dirs.count) top level folders in $Path" -ForegroundColor Cyan
    $data
}

The Get-ChildItem command is getting the top-level directories. When the command finishes, all directory objects will be stored in $dirs using OutVariable. Each directory object is also stored in $d using the PipelineVariable common parameter. This variable can be used later in the pipeline. I’m also storing the results of Measure-Object in a pipeline variable, $m. I can use these variables in the Select-Object expression to define custom properties using values derived from earlier in the pipeline expression. Remember, all the code starting with Get-Childitem is a single pipelined expression.

There is one final distinction between these variables. The $dirs variable defined with OutVariable persists after the pipeline expression completes.

PS C:\> > $dirs   

    Directory: C:\work

Mode                 LastWriteTime         Length Name
----                 -------------         ------ ----
d----           3/15/2022  7:23 PM                DSCModule
d----           4/24/2022  4:47 PM                foobar
d----           4/29/2022  3:22 PM                ironscripter
d----            7/7/2022  1:30 PM                samples
d----            7/8/2022 12:18 PM                stuff

But the pipeline variable does not.

PS C:\> $d
PS C:\>

Pipeline Trade-Offs

However, just because I can write this as a monolithic pipeline expression doesn’t mean I should. When I run this code against my scripts directory, it takes over 3 seconds to process 182 top-level folders. Or I could break it down into several separate steps that don’t require using the common parameters.

$path = 'c:\scripts'
$dirs = Get-ChildItem -Path $Path -Directory
foreach ($d in $dirs) {
    #Write-Host "Processing $($d.fullname)" -ForegroundColor green
    $m = Get-ChildItem -Path $d.fullname -File -Recurse | Measure-Object -Property length -Sum
    [pscustomobject]@{
        Path         = $d.fullname
        Name         = $d.Name
        LastModified = $d.lastWriteTime
        Files        = $m.count
        SizeKB       = [math]::Round($m.sum / 1kb, 2)
    }
} #foreach $d
Write-Host "Processed $($dirs.count) top level folders in $Path" -ForegroundColor Cyan

This shaved a couple of hundred milliseconds off with the bonus that I think this code is easier to read and troubleshoot.

Summary

There is a need for OutVariable and PipelineVariable with the latter being more of a special use case. Depending on how you structure your PowerShell expression, you might not even need them. But I wanted to ensure you knew about these parameters so you could pull them out when necessary.

(c) 2022-2025 JDH Information Technology Solutions, Inc. - all rights reserved
Don't miss what's next. Subscribe to Behind the PowerShell Pipeline:
Start the conversation:
GitHub Bluesky LinkedIn About Jeff
Powered by Buttondown, the easiest way to start and grow your newsletter.