Create your own
Lesson illustration

Enumerating Identity Records with foreach and Pipelines

Hello. In the previous lesson, you used if, elseif, and switch to classify one identity record conservatively—for example, as DoNotProvision, ManualReview, NoChange, or EligibleForProvisioning.

IAM work almost never stops at one record. A joiner feed, an Entra query, or an Active Directory search can return dozens or thousands of users. This lesson introduces the two main ways to run the same logic once per record in PowerShell:

  • the foreach statement, for a collection you already hold;
  • the ForEach-Object cmdlet, for objects flowing through a pipeline.

The important idea is unchanged from the first pipeline lesson: these are live objects with properties, not merely lines of displayed text. We will process records and emit decision objects, without making any directory changes.


Two similar names, two different enumeration styles

PowerShell has an unfortunately confusing naming overlap:

foreach ($user in $identityRecords) {
    # foreach statement
}
$identityRecords | ForEach-Object {
    # ForEach-Object cmdlet
}

Both execute a block once for each identity record. The difference is how the records arrive at the block.

ConstructWhere records come fromCurrent record variableTypical use
foreach statementAn existing collection, such as $identityRecordsA name you choose, such as $userYou already need the whole collection in memory.
ForEach-ObjectThe pipeline$_ or $PSItemA command is already producing objects one at a time.

Use the full spelling ForEach-Object in scripts. Although foreach can be an alias for that cmdlet, it looks too much like the separate foreach language statement and makes reviews harder.

about_Foreach - PowerShell | Microsoft Learn

Read Microsoft Learn's explanation of the foreach statement to establish the basic model: one collection, one item variable, and one pass through the body for each item.

In the Long description, Syntax, and first part of Examples, read the core example. Focus on what happens at the start of every iteration: PowerShell assigns the next collection item to the variable named in the parentheses.

There is a practical difference behind the syntax. When you write:

$identityRecords = Get-SomeIdentityRecords

foreach ($user in $identityRecords) {
    # Process $user
}

the command must finish producing its results before they are assigned to $identityRecords; the collection is retained before the loop begins. With ForEach-Object, the cmdlet receives each object from the pipeline and processes it as it arrives. For very large or open-ended result sets, that streaming behavior can be useful.

This is a design choice, not a security control. The safety of IAM automation still comes from correct conditions, conservative handling of unexpected data, and separation between deciding and changing access.


foreach: process a collection you already have

Here is a small in-memory collection representing four identity records. The [pscustomobject] syntax gives each record named properties, much like records returned by directory cmdlets. In the next module, you will work more deliberately with consistently shaped PSCustomObject data.

$identityRecords = @(
    [pscustomobject]@{
        UserPrincipalName = "maya.chen@contoso.com"
        AccountState      = "Enabled"
        Department        = "Finance"
        ManagerApproved   = $true
        Groups            = @("Employees", "MFA-Required")
    }

    [pscustomobject]@{
        UserPrincipalName = "omar.hassan@contoso.com"
        AccountState      = "Suspended"
        Department        = "Finance"
        ManagerApproved   = $true
        Groups            = @("Employees")
    }

    [pscustomobject]@{
        UserPrincipalName = "riley.smith@contoso.com"
        AccountState      = "Enabled"
        Department        = "Sales"
        ManagerApproved   = $true
        Groups            = @("Employees")
    }

    [pscustomobject]@{
        UserPrincipalName = "taylor.woods@contoso.com"
        AccountState      = "Enabled"
        Department        = "Finance"
        ManagerApproved   = $true
        Groups            = @("Employees", "Finance-Analyst")
    }
)

The outer @(...) creates an array. Each pass through this loop assigns one complete object to $user:

foreach ($user in $identityRecords) {
    [pscustomobject]@{
        UserPrincipalName = $user.UserPrincipalName
        AccountState      = $user.AccountState
        Department        = $user.Department
    }
}

Because the constructed PSCustomObject is emitted from the loop body, the loop produces four result objects. You can store them rather than merely displaying them:

$inventory = @(
    foreach ($user in $identityRecords) {
        [pscustomobject]@{
            UserPrincipalName = $user.UserPrincipalName
            AccountState      = $user.AccountState
            Department        = $user.Department
        }
    }
)

$inventory | Format-Table

The @(...) around the loop output ensures $inventory remains an array even if only one record is returned. Format-Table is deliberately at the end: $inventory contains useful objects until that final display command turns them into formatted console output.

During each iteration, $user refers to the current object, not a detached text rendering of it. That distinction matters:

foreach ($user in $identityRecords) {
    $user.Department
}

This returns the Department property of each identity object. Conversely, changing a property inside the loop changes the in-memory object you are referencing:

# Do not run this merely to report records.
foreach ($user in $identityRecords) {
    $user.Department = "Unknown"
}

That does not alter a directory, but it does mutate the local objects in $identityRecords. In IAM reporting and decision logic, prefer emitting a new result object rather than overwriting source data.

A PowerShell console creates an array containing `5`, `6`, and `7`, then sends each number to `ForEach-Object`; `$_ + 5` runs once per current item and outputs `10`, `11`, and `12`.

Put your access-decision logic inside the loop

A loop does not replace conditional logic. It supplies the current record so that the same policy is evaluated once per person.

For example, the following loop uses the access-processing priorities from the prior lesson. It outputs a decision and a reason for every record; it does not add anyone to a group.

$eligibleDepartments = @(
    "Finance"
    "Accounting"
)

$targetGroup = "Finance-Analyst"

$accessDecisions = @(
    foreach ($user in $identityRecords) {

        if ($user.AccountState -in @("Disabled", "Suspended", "Terminated")) {
            $decision = "DoNotProvision"
            $reason = "Account state blocks provisioning."
        }
        elseif ($user.AccountState -ne "Enabled") {
            $decision = "ManualReview"
            $reason = "Account state is not recognized as enabled."
        }
        elseif ($user.Department -notin $eligibleDepartments) {
            $decision = "ManualReview"
            $reason = "Department is not eligible for this group."
        }
        elseif (-not $user.ManagerApproved) {
            $decision = "ManualReview"
            $reason = "Manager approval is absent."
        }
        elseif ($targetGroup -in $user.Groups) {
            $decision = "NoChange"
            $reason = "User is already a group member."
        }
        else {
            $decision = "EligibleForProvisioning"
            $reason = "Baseline eligibility checks passed."
        }

        [pscustomobject]@{
            UserPrincipalName = $user.UserPrincipalName
            Decision          = $decision
            Reason            = $reason
        }
    }
)

$accessDecisions | Format-Table -AutoSize

The expected decisions are:

UserDecisionWhy
maya.chen@contoso.comEligibleForProvisioningEnabled Finance user, approved, and not already in the group
omar.hassan@contoso.comDoNotProvisionSuspended state takes priority
riley.smith@contoso.comManualReviewSales is outside the eligible departments
taylor.woods@contoso.comNoChangeAlready belongs to Finance-Analyst

Notice the scope of $user: within a particular iteration, it means one specific user. On the next iteration, it refers to the next record. Variables such as $eligibleDepartments and $targetGroup, defined outside the loop, remain available throughout every iteration.

For a policy that should produce exactly one decision per person, emit exactly one structured result from the loop body. This is a strong operational pattern: the input population and the output decision population remain easy to compare.


ForEach-Object: enumerate pipeline input

ForEach-Object is a cmdlet, so it receives objects from the pipeline:

$identityRecords | ForEach-Object {
    $_.UserPrincipalName
}

Inside its script block, $_ means the current pipeline object. $PSItem is an equivalent, more descriptive name:

$identityRecords | ForEach-Object {
    $PSItem.UserPrincipalName
}

Most PowerShell code uses $_, and you should be comfortable reading it. For multi-line IAM logic, assigning it a meaningful name immediately can make the body easier to audit:

$identityRecords | ForEach-Object {
    $user = $_

    "$($user.UserPrincipalName) has state $($user.AccountState)"
}

PowerShell ForEach-Object

Watch “PowerShell ForEach-Object” by Shane Young for a visual walkthrough of pipeline enumeration and the current-object variable.

Watch current-object processing. Focus on the fact that the block runs once per input object and that $_ changes to refer to the object currently moving through the pipeline.

Here is the same identity decision pattern written with ForEach-Object. The classification policy is identical; only the enumeration style changes.

$accessDecisions = @(
    $identityRecords | ForEach-Object {
        $user = $_

        if ($user.AccountState -in @("Disabled", "Suspended", "Terminated")) {
            $decision = "DoNotProvision"
            $reason = "Account state blocks provisioning."
        }
        elseif ($user.AccountState -ne "Enabled") {
            $decision = "ManualReview"
            $reason = "Account state is not recognized as enabled."
        }
        elseif ($user.Department -notin $eligibleDepartments) {
            $decision = "ManualReview"
            $reason = "Department is not eligible for this group."
        }
        elseif (-not $user.ManagerApproved) {
            $decision = "ManualReview"
            $reason = "Manager approval is absent."
        }
        elseif ($targetGroup -in $user.Groups) {
            $decision = "NoChange"
            $reason = "User is already a group member."
        }
        else {
            $decision = "EligibleForProvisioning"
            $reason = "Baseline eligibility checks passed."
        }

        [pscustomobject]@{
            UserPrincipalName = $user.UserPrincipalName
            Decision          = $decision
            Reason            = $reason
        }
    }
)

The pipeline version becomes especially natural when the records originate from a command:

Get-SomeIdentityRecords | ForEach-Object {
    $user = $_

    # Evaluate and emit one result for this user
}

At this point, avoid assuming that every command accepts pipeline input. Some parameters accept one identity when supplied directly but can accept many identities when values are piped in; others cannot. Check a cmdlet’s parameter documentation before designing a pipeline around it.


Preserve objects; do not confuse reporting with processing

Inside either loop type, your output choice determines whether later commands receive objects or merely display-oriented text.

This produces a structured object that can be assigned, exported, inspected, or passed onward:

[pscustomobject]@{
    UserPrincipalName = $user.UserPrincipalName
    Decision          = $decision
}

This displays text to the console but does not create a useful decision record for downstream processing:

Write-Host "$($user.UserPrincipalName): $decision"

For investigation or temporary progress messages, console output can be reasonable. But for IAM automation, structured output is usually more valuable because it supports later reporting, review, and evidence collection.

A good rule is:

  • Use foreach when you intentionally have a collection stored and want a clearly named current record.
  • Use ForEach-Object when records are already flowing through a pipeline.
  • In either construct, use the current record’s properties to make a decision.
  • Emit a new object that documents the decision rather than formatting output in the middle of the pipeline.
  • Keep state-changing commands out of a first-pass classification loop until the decision process has been reviewed.

Key takeaways

  • foreach ($user in $identityRecords) is a language statement that iterates an existing collection; $user is the current record.
  • ForEach-Object { ... } is a pipeline cmdlet; $_ and $PSItem refer to the current pipeline object.
  • Both approaches execute their script block once per identity record and can contain full conditional logic.
  • Loop variables reference live objects, so avoid accidentally mutating source records when you only intend to report on them.
  • Emit PSCustomObject results for usable decision data, and apply Format-Table only as a final display step.
  • Use the full ForEach-Object name rather than the ambiguous foreach alias in production-quality scripts.

Next, you will shape the stream of identity objects more deliberately: filtering records, selecting properties, sorting, and grouping results in the PowerShell pipeline.

Can't find a good explanation? Sign up and we'll make it for you

Sign up