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
foreachstatement, for a collection you already hold; - the
ForEach-Objectcmdlet, 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.
| Construct | Where records come from | Current record variable | Typical use |
|---|---|---|---|
foreach statement | An existing collection, such as $identityRecords | A name you choose, such as $user | You already need the whole collection in memory. |
ForEach-Object | The pipeline | $_ or $PSItem | A 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.

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:
| User | Decision | Why |
|---|---|---|
maya.chen@contoso.com | EligibleForProvisioning | Enabled Finance user, approved, and not already in the group |
omar.hassan@contoso.com | DoNotProvision | Suspended state takes priority |
riley.smith@contoso.com | ManualReview | Sales is outside the eligible departments |
taylor.woods@contoso.com | NoChange | Already 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)"
}
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
foreachwhen you intentionally have a collection stored and want a clearly named current record. - Use
ForEach-Objectwhen 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;$useris the current record.ForEach-Object { ... }is a pipeline cmdlet;$_and$PSItemrefer 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
PSCustomObjectresults for usable decision data, and applyFormat-Tableonly as a final display step. - Use the full
ForEach-Objectname rather than the ambiguousforeachalias 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