Exploring Group-Object in PowerShell
At some point in our ever-expanding PowerShell learning curve we’ve probably wanted to split a list based on a property, and written something along these lines:
$UsersWithX = @()
$UsersWithoutX = @()
foreach ($User in $Users) {
if ($User.PropertyX -eq 'VerySpecificValue') {
$UsersWithX += $User
}
else {
$UsersWithoutX += $User
}
}
It could be that we just retrieved a lot of AD users and we’re sorting them into lists based on whether they’re member of a specific AD group, or maybe we’re looking at server objects and checking how many there are with old operating systems.
You may be thinking “That could use some Where-Object
!”, but even then we would end up in a situation where we make the same comparison twice. Let’s use services as an example.
# Make sure to only gather the data once
$Services = Get-Service
$RunningServices = $Services | Where-Object Status -eq 'Running'
$StoppedServices = $Services | Where-Object Status -eq 'Stopped'
So while we definitely improved readability, you could argue that the code is less optimal as it has to evaluate the entire list of users twice. Not to say that the cmdlet is bad, sometimes it’s outclassed and sometimes it’s an amazing tool. But for our specific problem above, do we have any alternatives?
Group-Object
Group-Object
in PowerShell is an underestimated cmdlet which lets us solve the problem in a single command instead.
PipeHow:\Blog> $Services = Get-Service | Group-Object -Property Status
PipeHow:\Blog> $Services
Count Name Group
----- ---- -----
158 Stopped {...}
116 Running {...}
Grouping objects on a property makes it easy to structure data, and instead of making several lists we can simply use the resulting list of groups. Each group contains the elements matching whatever logic we grouped them by, and we immediately get a glance at how many they are and what they have in common by looking at the name.
Group Info
In our example above we grouped services by status, and we see that my current computer has 116 running services and 158 stopped ones. Running Get-Member
on a group in our collection shows us that each group object contains four properties that hold data about our objects.
PipeHow:\Blog> $Services[0] | Get-Member -MemberType Property
TypeName: Microsoft.PowerShell.Commands.GroupInfo
Name MemberType Definition
---- ---------- ----------
Count Property int Count {get;}
Group Property System.Collections.ObjectModel.Collection[psobject] Group {get;}
Name Property string Name {get;}
Values Property System.Collections.ArrayList Values {get;}
We can see that after grouping our list of services it no longer contains services, and examining the first object in the list shows us that it now contains objects of the type GroupInfo
. Each object has four properties about the objects grouped together based on the property or logic that we provided to the command.
Count
lets us know how many objects are in the group.Group
contains all objects that were grouped together.Name
is the name of the group, taken from the property or logic provided.Values
is a collection of all values we grouped the objects by.
The first three properties are pretty straight-forward, but Values
may not be as clear yet. To explain what I mean by “a collection of all values we grouped the object by” let’s take a look at the syntax of the cmdlet.
PipeHow:\Blog> (Get-Command Group-Object -Syntax) -replace ' \[',"`n["
Group-Object
[[-Property] <Object[]>]
[-NoElement]
[-AsHashTable]
[-AsString]
[-InputObject <psobject>]
[-Culture <string>]
[-CaseSensitive]
[<CommonParameters>]
Don’t mind the regex magic going on, I simply add a line break before each parameter for readability.
We can see that there are a few different ways to use the cmdlet. I will go through most of the parameters throughout the post, starting with the ones we’ve used so far. When using the pipeline as we have done, the InputObject
parameter is connected to whatever we send through the pipeline, and other than that we’ve only really used the Property
parameter which takes an array of objects.
An array of objects… Doesn’t that strike you as interesting? Not only does it mean that it can take several values and doesn’t necessarily expect just a single string with the name of a property to match by, it also means that it doesn’t even have to be a string!
Custom Grouping Logic
That’s right, we can create custom grouping logic using whatever code we want, as long as it results in an object of some type. To showcase this, let’s get and group all processes on my computer by:
- Whether or not the name starts with the same letter as the current weekday
- Whether or not the process has been running for more than 2 hours
- Whether or not it has a reference to an executable file
- Whether or not the process is responding
We will do this by creating a scriptblock for each grouping condition, which in turn will evaluate to a string that will become the group name.
$Now = Get-Date
# If the first character of the name is the same as the first character of today's weekday
$GroupCondition1 = {
if ($_.Name[0] -eq $Now.DayOfWeek.ToString()[0]) {
'Name=Today'
}
else {
'Name!=Today'
}
}
# If the process has run for at least two hours
$GroupCondition2 = {
if ($_.StartTime -lt $Now.AddHours(-2)) {
'Run > 2h'
}
else {
'Run < 2h'
}
}
# If the process has a reference to file (an executable)
$GroupCondition3 = {
if ([string]::IsNullOrWhitespace($_.Path)) {
'Is .exe'
}
else {
'Is not .exe'
}
}
# We can still group by the name of a property on the object
$GroupCondition4 = 'Responding'
$GroupConditions = ($GroupCondition1,$GroupCondition2,$GroupCondition3,$GroupCondition4)
$GroupedProcesses = Get-Process | Group-Object $GroupConditions
$GroupedProcesses | Sort-Object Count -Descending
Count Name Group
----- ---- -----
129 Name!=Today, Run > 2h, Is not .exe, True {...}
122 Name!=Today, Run > 2h, Is .exe, True {...}
5 Name!=Today, Run < 2h, Is not .exe, True {...}
5 Name=Today, Run > 2h, Is .exe, True {...}
1 Name!=Today, Run > 2h, Is not .exe, False {...}
1 Name=Today, Run > 2h, Is not .exe, True {...}
Using our conditions with the positional parameter Property
we can see that, among other groups, 129 of the processes on my computer do not start with a ‘W’ (this code is run on a Wednesday), have run for over two hours, do not have an executable file connected to them and are responding. The problem with simply grouping by a property such as Responding
that is either true or false, compared to the status of a service for example, is that you only get the literal true or false value in the GroupInfo
object and no information of what it represents.
This is why I like making the grouping scriptblocks return strings that are at least somewhat more clear as for what they mean. You could also attach custom properties to the objects and group by those, using for example Select-Object
.
Getting back to the Values
property, you can now probably see why it’s a list.
PipeHow:\Blog> $GroupedProcesses[0].Values
Name!=Today
Run < 2h
Is not .exe
True
It’s simply a collection of each property, or logic that we grouped by, and the result of it for this group. It’s what makes this group different from the other ones. The difference between this and the name is that this list has the actual values split into a list while the name is a concatenated string for readability.
NoElement
Let’s say we weren’t at all interested in what processes were in each group, we simply wanted a quick measurement of the custom grouping of our list. The parameter NoElement
takes care of that and gives us a trimmed result.
PipeHow:\Blog> Get-Service | Group-Object Status -NoElement
Count Name
----- ----
160 Stopped
114 Running
Using this parameter instead gives us objects of the type GroupInfoNoElement
with no objects in the Group
property, which is handy when we simply want a quick way to measure data and are not interested in the actual objects. It seems like two services stopped running during the time I wrote this blog post, unfortunately I’ll never know which ones, since as long as I use this parameter I only get the count of each group.
AsHashTable
If we turn it around from NoElement
where we don’t get the data of the grouped objects, you could say that AsHashTable
does the exact opposite. It omits the Count
property and creates a hashtable with the name of each group as the key and a list of all the grouped objects as the value. You can of course still count them using the properties of the hashtable, but this lets you access the data in a very quick and structured way.
PipeHow:\Blog> $ServiceHash = Get-Service | Group-Object Status -AsHashTable
PipeHow:\Blog> $ServiceHash['Running'].Count
0
We created a hashtable, but something strange occurred. When trying to access the collection of running services we find none. This is because the keys of a hashtable are not restricted to being only strings. In our case, the Status
property that we group by is not actually a string, as can be seen if we inspect a service object.
PipeHow:\Blog> Get-Service | Get-Member Status
TypeName: System.ServiceProcess.ServiceController
Name MemberType Definition
---- ---------- ----------
Status Property System.ServiceProcess.ServiceControllerStatus Status {get;}
PipeHow:\Blog> [enum]::GetValues([System.ServiceProcess.ServiceControllerStatus])
Stopped
StartPending
StopPending
Running
ContinuePending
PausePending
Paused
It’s actually an enum! While PowerShell does it’s best to present it as a readable string to us in the console, an enum is actually a data structure of its own. If you’re interested in reading more about them I wrote a post on classes and enums in PowerShell.
We can however combine AsHashTable
with another parameter called AsString
to force PowerShell to convert the keys to strings. Note that this may not always give you the result you expect, so if any problems occur when formatting the keys I would suggest you make sure to return a custom string in the group condition instead.
PipeHow:\Blog> $ServiceHash = Get-Service | Group-Object Status -AsHashTable -AsString
PipeHow:\Blog> $ServiceHash['Running'].Count
114
CaseSensitive
As with most things in PowerShell, Group-Object
is not case-sensitive by default. We can however make sure that it is, by specifying the CaseSensitive
parameter.
Let me demonstrate how it works by creating a couple of nonsense objects with names and values.
$TestLowerCase = [pscustomobject]@{
'Name' = 'test'
'Value' = 'lowercase'
}
$TestUpperCase = [pscustomobject]@{
'Name' = 'TEST'
'Value' = 'uppercase'
}
$TestTitleCase = [pscustomobject]@{
'Name' = 'Test'
'Value' = 'titlecase'
}
$TestList = $TestLowerCase,$TestUpperCase,$TestTitleCase
These three objects all have the name “test” but in different casing. Using the CaseSensitive
parameter lets us differentiate between them in the groups.
PipeHow:\Blog> $TestList | Group-Object Name
Count Name Group
----- ---- -----
3 test {@{Name=test; Value=lowercase}, @{Name=TEST; Value=uppercase}, @{Name=Test; Value=titlecase}}
PipeHow:\Blog> $TestList | Group-Object Name -CaseSensitive
Count Name Group
----- ---- -----
1 test {@{Name=test; Value=lowercase}}
1 TEST {@{Name=TEST; Value=uppercase}}
1 Test {@{Name=Test; Value=titlecase}}
As keys in a hashtable need to be unique, does this also work with the AsHashTable
parameter? Only if you are running PowerShell 7 or up, in earlier PowerShell versions we will get an error about key duplication.
Beginning in PowerShell 7, Group-Object can combine the CaseSensitive and AsHashtable parameters to create a case-sensitive hash table. The hash table keys use case-sensitive comparisons and output a System.Collections.Hashtable object.
I hope you learned something new about grouping data in PowerShell, and hopefully you can improve some of those old nested comparison loops!