Blog Post

Exchange Team Blog
4 MIN READ

More Efficient Bulk Operations with PowerShell Parallelism

The_Exchange_Team's avatar
Apr 30, 2025

Optimizing the utilization of PowerShell processing through multi-threading techniques such as runspaces and multiple jobs can be done in several ways. These techniques can often be intricate and demand significant effort to implement, particularly for individuals new to PowerShell. This blog post will present an alternative approach to multi-threading utilizing a simple parameter, without the complexities generally associated with creating and managing of runspaces and multiple jobs in PowerShell.

What is the switch?

The “Parallel” switch, introduced in PowerShell 7, allows script blocks to execute concurrently for each piped input object. This feature is particularly advantageous for bulk operations where tasks can be performed simultaneously. By utilizing this capability, one can significantly reduce processing time and improve efficiency.

This functionality requires PowerShell 7. You can follow this article to upgrade to PS v7. Older versions of PowerShell, such as version 5, do not support the Parallel switch.

How to use this?

The Parallel switch is available only when you write a ForEach-Object loop.

The ‘foreach’ which is an alias of the ForEach-Object loop is not supported currently.

For example: 

$data | ForEach-Object -Parallel { script block }

The Parallel switch will open PowerShell instances hidden in the background but connected to the currently open instance and run the script block simultaneously in those instances. Any errors or outputs will be displayed in the currently open parent instance. The number of parallel instances to be opened can be defined with the ThrottleLimit switch trailing behind the script block. The default ThrottleLimit is 5 and the PowerShell will open 5 parallel session if the ThrottleLimit switch is not defined.

$data | ForEach-Object -Parallel { script block } -ThrottleLimit 5

Considerations

  • Please note that Parallel sessions run in their own isolated spaces and do not share dependencies amongst themselves. Therefore, you will need to define all necessary elements, such as authentication tokens, and modules (if required), inside the script block.
  • Each parallel session will use the available system resources and thus the throttle limit should be set to the number of available cores on the system.
  • Bear in mind that this parallelising feature in PowerShell reduces the overall time taken for a script to run serially. Thus, this it is useful for tasks that are independent of previous executions, such as bulk operations, specially as it reduces overall time and effort taken for the bulk operation.

A real-life example

Problem: A colleague requested assistance in adding trusted domains and email addresses to 15,000 mailboxes. Processing of each object takes ~5 seconds to complete, resulting in a total of ~75,000 seconds (~20.8 hours).

Solution: The following code was provided to complete the task in less than 5 hours:

$users = ipcsv C:\input_file_location.csv
$users | ForEach-Object -Parallel {
$conn = Get-Mailbox -Identity valid-test-account@domain.com
if ($conn -eq $null) {
Connect-ExchangeOnline -UserPrincipalName exo-admin-account@domain.com -UseMultithreading:$true }

Set-MailboxJunkEmailConfiguration -Identity $_.identity -TrustedSendersAndDomains @{Add="no-reply@sharepointonline.com","noreply@yammer.com","noreply@planner.office365.com" } -ThrottleLimit 5

Disconnect-ExchangeOnline -Confirm:$false  

Script explanation:

The following line would create a variable called $users and import the data from csv file located at the location “C:\input_file_location.csv”:

$users = ipcsv C:\input_file_location.csv

About the following block... As parallel sessions are opened independently, the Auth token from one session cannot be transferred to another session due to the token’s secure design. Therefore, the command to connect to Exchange Online (EXO) must be placed within the parallel loop so that the loop gets logged in to EXO. However, this approach will initiate as many PowerShell connections to EXO as there are users in the $users variable. This can result in the account being flagged in Risky Sign-Ins, with subsequent connections after the first ten being blocked.

An effective way to address this is to implement a conditional login to check if the session is already connected and connect only if it is not. By using a simple Get-Mailbox command for any test user in the environment to verify that the session is connected, and storing the result in $conn, the parallel session will only connect to EXO if $conn is null. This approach ensures that only one connection is made for each parallel session and addresses the risk of the account being flagged for Risky Sign-Ins.

Further, using the SkipLoadingCmdletHelp switch prevents the help file to be downloaded and occupy memory resources which can overburden the system. This is an optional switch and is required only if using Exchange Online PowerShell module older than version 3.7. This switch is not available in module ver. 3.7 and above. See this post for more information.

$users | ForEach-Object -Parallel {
$conn = Get-Mailbox -Identity valid-test-account@domain.com
if ($conn -eq $null) {
Connect-ExchangeOnline -UserPrincipalName exo-admin-account@domain.com -SkipLoadingCmdletHelp}

Sharing data between the parent loop and the parallel sessions can be achieved using the $using technique; however, this will be covered in the next blog post!

Thank you for reading!

Abhijeet Kowale and Indraneel Roy

Updated Apr 30, 2025
Version 2.0

12 Comments

  • Satyajit321's avatar
    Satyajit321
    Iron Contributor

    Good usecase and start in bringing this back up, more examples on larger datasets would help. The first thing that strikes as soon as we read parallel is throttling in EXO\Graph, as already called out by niehweune​ is a major blocking\consideration factor. Which once triggers starts breaking down everything. Once that happens, what failed, what worked tracking them becomes a challange and 20hrs serial appears to be much desirable\realistic instead of -Parallel. PS7 and EXO modules never worked fine for me, intermittent JSON errors still on the newer modules check last week.

  • stukey's avatar
    stukey
    Iron Contributor

    Agreed with Peter. Couldn’t you use Get-ConnectionInformation to validate the status of the existing connection to Exchange Online?

    • AVK's avatar
      AVK
      Icon for Microsoft rankMicrosoft

      Hi stukey, 

      Thank you for reaching out!

      The Get-ConnectionInformation command does not work as expected in the Parallel loop and so it might result in more than required (as defined in throttle limit) connections to EXO.

      • stukey's avatar
        stukey
        Iron Contributor

        Thanks for the additional info. I think a few more examples of how to use this approach would be really useful!

  • Peter Holdridge's avatar
    Peter Holdridge
    Copper Contributor

    I get it, so it divides the number of loops into 5, each process requires an auth token, but only needs to acquire it one time since it already populated $conn variable. But it seems to me there would be an easier way to test the connection without running a get mailbox command which takes up time and resources. Can't you test for the existence of the token? Or just save the output of the Connect cmdlet to a variable and check for null on that?

    • AVK's avatar
      AVK
      Icon for Microsoft rankMicrosoft

      Thank you, Peter, for your suggestion!

      Unlike Basic authentication, the Connect command is much more secure, and its output cannot be saved into a variable. Additionally, you might notice that we aren't clearing the $conn variable in the script. This means the script won't check every time and will only connect to EXO at the beginning of the script. However, this approach might not work if the EXO connection breaks midway due to any issue or if the PIM role expires and EXO admin access is lost. This can be easily managed by adding the -ErrorAction Stop switch. But we can leave this for the admin to decide and use if needed.

      Furthermore, even if the variable is cleared and rebuilt every time, we haven't seen any significant delay in testing this approach with the Get-Mailbox cmdlet. However, it also depends on the network, the mailbox location, and other factors.

      Also, we have used Get-ConnectionInformation and tried to test, but it doesn't support and work as intended. Anything else is making things complicated and the goal here is to get the bulk operation done faster. So, to achieve the goal and keep it simple, we finalized on the Get-Mailbox cmdlet.

      Lastly, the purpose of this blog post was to introduce the idea of parallelism and provide an example for the use. The script shared is just an example and by all means, you can make changes and improvements to the example as fits you best!

       

    • niehweune's avatar
      niehweune
      Brass Contributor

      Same reflection here, imho it would be much simpler to check with Get-ConnectionInfomation if we are already connected. Much less processing overhead and reduces the number of web requests by 50%.
      However, tests show the connection is returned, even if it's made from another thread (which doesn't really make sense if the -Parallel thread is started as a different process).

      A working trick is to use Test-Path to see if the cmdlet we need is available inside our thread (i.e. if we don't have a connection, we don't have the cmdlet).


      Some more room for improvement here:
      - Importing a CSV in an array in memory then processing each item in the array causes memory overhead, using the pipeline directly is much more efficient
      - Explicitly specifying -UseMultithreading:$true for Connect-ExchangeOnline makes no sense, since this only applies to the *-EXO cmdlets, and $True is the default value anyway
      - Connect-ExchangeOnline has this -CommandName switch, which allows you to only load the commands you need (in this case, just one), so this may significantly improve memory consumption
      - Connect-ExchangeOnline defaults to WAM nowadays, which throws an error if it is called simultaniously (so we should add a -DisableWAM switch, or even better connect using an App & certificate and not an actual user)
      - Disconnect-ExchangeOnline doesn't seem to make sense either, since we're connecting in a child process... Although it seems that somehow, the connection made in the child process is returned when calling Get-ConnectionInformation from the parent (so somehow the connection is seen by the parent process)
      - Instead of setting the throttlelimit hardcoded to 5, you could use the number of cores to speed things up even more

      try {
          $Cores = Get-CimInstance CIM_ComputerSystem | Select-Object -ExpandProperty NumberOfLogicalProcessors
      } catch {
          $Cores = 5 # in case CIM call fails
      }
      
      Import-CSV C:\input_file_location.csv | ForEach-Object -Parallel {
          if (Test-Path (Function:Set-MailboxJunkEmailConfiguration)) {
              Connect-ExchangeOnline -UserPrincipalName "UPN" -DisableWAM -CommandName Set-MailboxJunkEmailConfiguration -DisableWAM -ShowBanner:$false
          }
          Set-MailboxJunkEmailConfiguration -Identity $_.identity -TrustedSendersAndDomains @{Add="email address removed for privacy reasons","email address removed for privacy reasons","email address removed for privacy reasons"
      } -ThrottleLimit $Cores
      
      Disconnect-ExchangeOnline -Confirm:$false


      A few more remarks:
      - 'Regular' ForEach-Process supports -Begin -Process -End parameters (similar to a function), it's a pity the pwsh devs didn't provide the same functionality when using -Parallel (this would be an excellent use case)
      - Beware of throttling - EOM REST calls follow the Graph API throttling limits, so although you can now have more than 5 threads (as apposed to the 'old' PSSession to EXO), there's still some limits as to the number of requests you can throw at EXO/Graph within a given timeframe.
      - Also be aware that you need at least PowerShell 7.1 for this to work correctly. The -parallel parameter is available in PS 7.0, but that version creates a new runspace for every iteration (basically meaning you would end up creating a new EXO connection for each individual row in the Excel)


      [Update]
      Looks like Get-ConnectionInformation inside the child thread doesn't work, so updated to use Test-Path instead

      • AVK's avatar
        AVK
        Icon for Microsoft rankMicrosoft

        Thanks niehweune,

        The script provided is a functional example designed to illustrate the feature and the switch. This blog post aims to introduce the concept of parallelism in PowerShell using the Parallel switch and offers a practical example for its application. As you may have noticed, there are limited resources available online to assist beginners explaining to connect individually within the loop.

        Thus, in this blog, we have emphasized the feature by using a straightforward script so by all means, you or anyone can make changes and improvements to the example as fits you best!