Bulk-Migrate Distribution Lists from On-Prem Exchange to Exchange Online with PowerShell (2026 Guide)

Migrating distribution lists from on-premises Exchange to Exchange Online is one of those tasks that sounds like it should be a single button in the admin centre, and absolutely is not. The lists exist in AD, the addresses overlap with mailboxes you may or may not have already migrated, the membership often includes mail contacts that match users, and Microsoft’s own documentation skips the awkward parts. This post walks through the full workflow I actually use in production: export each DL from on-prem to a CSV, then bulk-create them in Exchange Online from those CSVs. Both scripts included, with the error patterns you will hit in a real hybrid environment.

If you just want the scripts, skip to the export script or the bulk-create script.

The workflow at a glance

The approach has three phases:

  1. Export each on-prem distribution list to its own CSV file using a script run against your on-prem Exchange server. One CSV per DL keeps things auditable and easy to re-run for individual groups.
  2. Create relay contacts on-prem (only if your MX still points to on-prem Exchange) — mail-enabled contacts that forward inbound mail to the cloud versions of the DLs so nothing bounces during the transition window.
  3. Bulk-create the DLs in Exchange Online by reading those CSV files (or a consolidated one) and calling New-DistributionGroup against the cloud tenant.

Splitting the work into phases gives you a clean handoff point at each stage. You can review the CSVs before pushing anything to the cloud, edit memberships on disk, and re-run individual groups without re-exporting everything.

Prerequisites

For the on-prem export side:

  • An account with at least View-Only Recipient Management on Exchange
  • PowerShell remoting reachable to your Exchange server over HTTP (port 80) using Kerberos
  • Run from a domain-joined Windows machine — the script uses Windows SSO

For the Exchange Online bulk-create side:

  • ExchangeOnlineManagement module v3.x or higher: Install-Module -Name ExchangeOnlineManagement -Scope CurrentUser
  • An account with the Recipient Management role in the cloud tenant
  • The CSV files produced by the export script

The old MSOnline module is deprecated and the cmdlets behave differently than the current documentation describes. Use ExchangeOnlineManagement v3.x.

Exporting distribution lists from on-prem Exchange

This script connects to your on-prem Exchange server, reads the DL you specify, resolves owners and members (with optional nested group expansion), and writes a CSV file named after the DL’s display name. Run it once per DL, or wrap it in a loop to process all of them.

<#
.SYNOPSIS
    Exports an on-prem Exchange distribution list to a CSV ready for
    bulk re-creation in Exchange Online.
.PARAMETER ExchangeServer
    Hostname of your on-prem Exchange server.
.PARAMETER Identity
    Distribution group name, alias, or SMTP. Prompted if omitted.
.PARAMETER OutDir
    Directory to write the CSV to. Defaults to the current directory.
.PARAMETER ExpandNestedMembers
    If set, recursively expands group-of-groups membership into a flat list.
.EXAMPLE
    .\Export-DL-RecreateCsv.ps1 -ExchangeServer "ex01.corp.local" -Identity "Marketing Team" -OutDir "C:\DLExports"
#>
[CmdletBinding()]
param(
    [Parameter(Mandatory = $true)]
    [string]$ExchangeServer,

    [Parameter(Mandatory = $false)]
    [string]$Identity,

    [Parameter(Mandatory = $false)]
    [string]$OutDir = ".",

    [switch]$ExpandNestedMembers
)

function Sanitize-FileName {
    param([string]$Name)
    $invalid = [System.IO.Path]::GetInvalidFileNameChars()
    foreach ($c in $invalid) { $Name = $Name.Replace($c, "_") }
    return $Name.Trim()
}

function To-SemicolonList {
    param([string[]]$Values)
    if (-not $Values) { return "" }
    $clean = $Values |
        Where-Object { $_ -and $_.Trim() -ne "" } |
        ForEach-Object { $_.Trim() } |
        Sort-Object -Unique
    if (-not $clean) { return "" }
    return ($clean -join ";")
}

function Ensure-ExchangeCmdlets {
    param(
        [Parameter(Mandatory = $true)]
        [string]$Server
    )

    # If Exchange cmdlets are already imported in this PowerShell window, do nothing.
    $existingCommand = Get-Command Get-DistributionGroup -ErrorAction SilentlyContinue
    if ($existingCommand) {
        Write-Host "Exchange cmdlets already available in current session. Skipping import."
        return $null
    }

    # Reuse an existing Exchange remote session if one is open.
    $existingSession = Get-PSSession | Where-Object {
        $_.ConfigurationName -eq "Microsoft.Exchange" -and $_.State -eq "Opened"
    } | Select-Object -First 1

    if ($existingSession) {
        Write-Host "Using existing Exchange remote session."
        Import-PSSession $existingSession -DisableNameChecking -AllowClobber -ErrorAction Stop | Out-Null
        return $existingSession
    }

    # Otherwise create a new SSO session with the current Windows login.
    $uri = "http://$Server/PowerShell/"
    Write-Host "Creating new Exchange remote session: $uri"

    $session = New-PSSession `
        -ConfigurationName Microsoft.Exchange `
        -ConnectionUri $uri `
        -Authentication Kerberos `
        -ErrorAction Stop

    Import-PSSession $session -DisableNameChecking -AllowClobber -ErrorAction Stop | Out-Null
    return $session
}

function Get-GroupMembersFlat {
    param(
        [Parameter(Mandatory = $true)]
        [string]$GroupIdentity,
        [switch]$ExpandNested
    )

    $result = New-Object System.Collections.Generic.List[string]
    $seenGroups = New-Object System.Collections.Generic.HashSet[string]

    function Add-MembersInternal {
        param([string]$Gid)

        $members = @(Get-DistributionGroupMember -Identity $Gid -ResultSize Unlimited -ErrorAction Stop)
        foreach ($m in $members) {
            $isGroup = $false
            if ($m.RecipientTypeDetails -match "Group") { $isGroup = $true }
            elseif ($m.RecipientType -match "Group") { $isGroup = $true }

            if ($ExpandNested -and $isGroup) {
                $childId = $m.Identity.ToString()
                if ($seenGroups.Add($childId)) { Add-MembersInternal -Gid $childId }
            }
            else {
                $addr = $null
                if ($m.PrimarySmtpAddress) { $addr = $m.PrimarySmtpAddress.ToString() }
                elseif ($m.WindowsEmailAddress) { $addr = $m.WindowsEmailAddress.ToString() }
                elseif ($m.ExternalEmailAddress) { $addr = $m.ExternalEmailAddress.ToString() }
                else { $addr = $m.Name }

                if ($addr) { [void]$result.Add($addr) }
            }
        }
    }

    if ($ExpandNested) { [void]$seenGroups.Add($GroupIdentity) }
    Add-MembersInternal -Gid $GroupIdentity
    return $result
}

# Prompt for DL identity if not given.
if (-not $Identity -or $Identity.Trim() -eq "") {
    $Identity = Read-Host "Enter Distribution Group Identity (name / alias / SMTP)"
}

if (-not (Test-Path -LiteralPath $OutDir)) {
    New-Item -ItemType Directory -Path $OutDir -Force | Out-Null
}

try {
    Ensure-ExchangeCmdlets -Server $ExchangeServer | Out-Null

    try {
        $dg = Get-DistributionGroup -Identity $Identity -ErrorAction Stop
    }
    catch {
        throw "Could not find Distribution Group '$Identity'. Error: $($_.Exception.Message)"
    }

    # Resolve owners to SMTP addresses.
    $owners = @()
    if ($dg.ManagedBy) {
        foreach ($o in $dg.ManagedBy) {
            try {
                $r = Get-Recipient -Identity $o -ErrorAction Stop
                if ($r.PrimarySmtpAddress) { $owners += $r.PrimarySmtpAddress.ToString() }
                elseif ($r.WindowsEmailAddress) { $owners += $r.WindowsEmailAddress.ToString() }
                else { $owners += $r.Name }
            }
            catch { $owners += $o.ToString() }
        }
    }

    # Flatten members.
    $memberList = @()
    try {
        $memberList = Get-GroupMembersFlat -GroupIdentity $dg.Identity.ToString() -ExpandNested:$ExpandNestedMembers
    }
    catch {
        Write-Warning "Could not read members for '$($dg.DisplayName)': $($_.Exception.Message)"
    }

    $hasMembers = if ($memberList.Count -gt 0) { "YES" } else { "NO" }

    # Collect secondary SMTP addresses.
    $primary = ""
    if ($dg.PrimarySmtpAddress) { $primary = $dg.PrimarySmtpAddress.ToString().ToLower() }

    $secondary = @()
    foreach ($ea in @($dg.EmailAddresses)) {
        $s = $ea.ToString()
        if ($s -match '^(SMTP|smtp):') {
            $addr = $s.Split(":", 2)[1].Trim()
            if ($addr -and ($addr.ToLower() -ne $primary)) { $secondary += $addr }
        }
    }

    # MemberOf for documentation purposes.
    $memberOf = @()
    try {
        $rec = Get-Recipient -Identity $dg.Identity -ErrorAction Stop
        if ($rec.MemberOf) {
            foreach ($mo in $rec.MemberOf) {
                try {
                    $g = Get-Recipient -Identity $mo -ErrorAction Stop
                    if ($g.PrimarySmtpAddress) { $memberOf += $g.PrimarySmtpAddress.ToString() }
                    else { $memberOf += $g.Name }
                }
                catch { $memberOf += $mo.ToString() }
            }
        }
    }
    catch {
        Write-Warning "Could not resolve MemberOf for '$($dg.DisplayName)': $($_.Exception.Message)"
    }

    $groupType = "DistributionGroup"
    if ($dg.RecipientTypeDetails -eq "MailUniversalSecurityGroup") {
        $groupType = "MailSecurityGroup"
    }

    $row = [PSCustomObject]@{
        DisplayName          = $dg.DisplayName
        Alias                = $dg.Alias
        PrimarySmtpAddress   = if ($dg.PrimarySmtpAddress) { $dg.PrimarySmtpAddress.ToString() } else { "" }
        Owner                = To-SemicolonList $owners
        Members              = To-SemicolonList $memberList
        SecondaryEmails      = To-SemicolonList $secondary
        Description          = if ($dg.Notes) { $dg.Notes } else { "" }
        GroupType            = $groupType
        MemberOf             = To-SemicolonList $memberOf
        MemberOfOutsideScope = "NO"
        HasGroupMembers      = $hasMembers
    }

    $baseName = if ($dg.DisplayName) { $dg.DisplayName } else { $dg.Alias }
    $fileName = (Sanitize-FileName $baseName) + ".csv"
    $outPath = Join-Path $OutDir $fileName

    $row | Export-Csv -Path $outPath -NoTypeInformation -Encoding UTF8

    Write-Host ""
    Write-Host "Export completed: $outPath"
}
catch {
    Write-Error $_.Exception.Message
}
finally {
    # Do NOT remove the session — keeping it open lets the next script run
    # in the same PowerShell window reuse the loaded Exchange cmdlets.
}

A few notes on the export script:

  • It deliberately does not dispose of the PSSession at the end. If you are batch-exporting fifty DLs, you do not want the script reconnecting to Exchange every iteration. Run them in the same PowerShell window and they share the session.
  • The -ExpandNestedMembers switch flattens groups-of-groups into a single member list. Useful if Exchange Online does not have the same nested groups, or if you want a clean snapshot of who actually receives mail.
  • MemberOfOutsideScope is hardcoded to "NO" — this is a placeholder you can populate manually if you have groups that depend on memberships outside what you are migrating.

To export every distribution group in one go, wrap the script in a loop:

Get-DistributionGroup -ResultSize Unlimited | ForEach-Object {
    .\Export-DL-RecreateCsv.ps1 -ExchangeServer "ex01.corp.local" -Identity $_.Identity -OutDir "C:\DLExports"
}

That writes one CSV per DL into C:\DLExports. Inspect the folder before running the import side. You will sometimes find DLs you did not know existed, half-empty groups, and addresses with collisions you need to plan around.

Consolidating the export CSVs into one file

The bulk-create script wants a single CSV with one DL per row. After the export run, combine all the per-DL files:

$all = Get-ChildItem -Path "C:\DLExports" -Filter *.csv | ForEach-Object {
    Import-Csv -Path $_.FullName
}
$all | Export-Csv -Path "C:\DLExports\_consolidated.csv" -NoTypeInformation -Encoding UTF8

You can also edit the consolidated CSV by hand — remove DLs you do not want to migrate, fix addresses, prune membership.

If your MX record still points to on-prem: create a relay contact first

Before you run the bulk-create script, check where your MX record currently points. This step only applies if mail for your domain is still flowing through your on-prem Exchange server — which is the normal state during a phased migration where you have not yet cut over the MX.

Why this matters: when you create a DL in Exchange Online and your MX still points on-prem, inbound mail for that DL arrives at your on-prem Exchange server. On-prem Exchange looks up the address, finds nothing local (because the DL now only exists in the cloud), and bounces the message. Users start getting NDRs. The DL effectively stops working the moment you recreate it in the cloud.

The fix: create a mail contact on-prem that relays to the cloud version of the DL. On-prem Exchange sees the contact, forwards the message to the cloud address, and Exchange Online delivers it to the DL members. Mail keeps flowing during the transition period.

How to identify the cloud relay address

When Exchange Online creates a DL, it assigns it a primary SMTP address in your cloud-only domain — the .onmicrosoft.com address. This is the address the on-prem contact needs to point to because it is guaranteed to route into Exchange Online regardless of where the MX points.

For a DL whose on-prem address is mydl@onpremdomain.com, the cloud relay address will be something like:

mydl@yourtenant.mail.onmicrosoft.com

Find it in Exchange Online after creating the DL:

Get-DistributionGroup -Identity "mydl@onpremdomain.com" |
    Select-Object -ExpandProperty EmailAddresses |
    Where-Object { $_ -like "*.mail.onmicrosoft.com" }

Creating the on-prem relay contact

Run this against your on-prem Exchange (in the same PSSession used for the export script):

# Create a mail contact on-prem pointing to the cloud DL's .onmicrosoft.com address
New-MailContact `
    -Name "mydl-cloud-relay" `
    -DisplayName "My DL (Cloud)" `
    -ExternalEmailAddress "mydl@yourtenant.mail.onmicrosoft.com" `
    -OrganizationalUnit "OU=MailContacts,DC=corp,DC=local"

# Add the on-prem primary address as a proxy address on the contact
# so on-prem Exchange accepts mail for it and routes it through the contact
Set-MailContact `
    -Identity "mydl-cloud-relay" `
    -EmailAddresses @{
        Add = "SMTP:mydl@onpremdomain.com"
    }

# If the DL had secondary addresses on-prem, add those too
# so replies and aliases also route through
Set-MailContact `
    -Identity "mydl-cloud-relay" `
    -EmailAddresses @{
        Add = "smtp:mydl-alias@onpremdomain.com"
    }

Note the case convention: SMTP: (uppercase) sets the primary address; smtp: (lowercase) adds a secondary proxy address. You only ever have one uppercase SMTP: on a recipient.

Bulk-creating the relay contacts from the consolidated CSV

If you are migrating many DLs at once, doing this by hand is not practical. This script reads your consolidated CSV and creates the on-prem relay contacts in a batch. Run it against your on-prem Exchange session before running the cloud bulk-create:

<#
.SYNOPSIS
    Creates on-prem mail contacts as relay targets for DLs being
    migrated to Exchange Online. Run this only if your MX still
    points to on-prem Exchange.
.PARAMETER CsvPath
    Path to the consolidated DL CSV.
.PARAMETER TenantDomain
    Your .mail.onmicrosoft.com tenant domain, e.g. "yourtenant.mail.onmicrosoft.com"
.PARAMETER ContactOU
    OU where relay contacts will be created, e.g. "OU=MailContacts,DC=corp,DC=local"
.EXAMPLE
    .\New-DLRelayContacts.ps1 -CsvPath C:\DLExports\_consolidated.csv `
        -TenantDomain "yourtenant.mail.onmicrosoft.com" `
        -ContactOU "OU=MailContacts,DC=corp,DC=local"
#>
[CmdletBinding()]
param(
    [Parameter(Mandatory)]
    [ValidateScript({ Test-Path $_ })]
    [string]$CsvPath,

    [Parameter(Mandatory)]
    [string]$TenantDomain,

    [Parameter(Mandatory)]
    [string]$ContactOU
)

$dls = Import-Csv -Path $CsvPath

foreach ($dl in $dls) {
    # Derive the alias from the primary SMTP address (part before the @)
    $alias = ($dl.PrimarySmtpAddress -split '@')[0]
    $cloudRelayAddress = "$alias@$TenantDomain"
    $contactName = "$alias-cloud-relay"

    Write-Host "`nProcessing: $($dl.DisplayName)" -ForegroundColor Cyan

    # Skip if a contact with this name already exists
    $existing = Get-MailContact -Identity $contactName -ErrorAction SilentlyContinue
    if ($existing) {
        Write-Host "  Skipping — relay contact already exists: $contactName" -ForegroundColor Yellow
        continue
    }

    try {
        # Create the relay contact pointing to the cloud .onmicrosoft.com address
        New-MailContact `
            -Name $contactName `
            -DisplayName "$($dl.DisplayName) [Cloud]" `
            -ExternalEmailAddress $cloudRelayAddress `
            -OrganizationalUnit $ContactOU `
            -ErrorAction Stop | Out-Null

        Write-Host "  Created contact → $cloudRelayAddress" -ForegroundColor Green

        # Add the on-prem primary SMTP as a proxy address so mail
        # sent to the original address routes through this contact
        Set-MailContact `
            -Identity $contactName `
            -EmailAddresses @{ Add = "SMTP:$($dl.PrimarySmtpAddress)" } `
            -ErrorAction Stop

        Write-Host "  Added proxy: $($dl.PrimarySmtpAddress)" -ForegroundColor DarkGreen

        # Add any secondary addresses as proxy addresses
        if ($dl.SecondaryEmails) {
            $secondaries = $dl.SecondaryEmails -split ';' |
                ForEach-Object { $_.Trim() } |
                Where-Object { $_ }

            foreach ($s in $secondaries) {
                try {
                    Set-MailContact `
                        -Identity $contactName `
                        -EmailAddresses @{ Add = "smtp:$s" } `
                        -ErrorAction Stop
                    Write-Host "  Added proxy: $s" -ForegroundColor DarkGreen
                } catch {
                    Write-Host "  ! Failed to add proxy $s : $($_.Exception.Message)" -ForegroundColor Red
                }
            }
        }

        Start-Sleep -Seconds 2  # Throttle between contact creates
    } catch {
        Write-Host "  FAILED: $($_.Exception.Message)" -ForegroundColor Red
    }
}

Write-Host "`nRelay contact creation complete."
Write-Host "Once your MX record is cut over to Exchange Online, delete these contacts."

Run it like this:

.\New-DLRelayContacts.ps1 `
    -CsvPath "C:\DLExports\_consolidated.csv" `
    -TenantDomain "yourtenant.mail.onmicrosoft.com" `
    -ContactOU "OU=MailContacts,DC=corp,DC=local"

Cleaning up after MX cutover

Once you have pointed the MX record at Exchange Online and mail is flowing correctly to the cloud, the relay contacts on-prem are no longer needed and should be removed. Leaving them creates confusing duplicate recipient entries in the on-prem GAL.

# Remove relay contacts after MX cutover
$dls = Import-Csv -Path "C:\DLExports\_consolidated.csv"

foreach ($dl in $dls) {
    $alias = ($dl.PrimarySmtpAddress -split '@')[0]
    $contactName = "$alias-cloud-relay"

    $contact = Get-MailContact -Identity $contactName -ErrorAction SilentlyContinue
    if ($contact) {
        Remove-MailContact -Identity $contactName -Confirm:$false
        Write-Host "Removed: $contactName" -ForegroundColor Green
    }
}

Run this only after you have confirmed mail is routing correctly through Exchange Online end-to-end. A quick test before removing: send a message to the original DL address and verify it reaches cloud members. Then remove the contacts.

Bulk-creating the DLs in Exchange Online

This script reads the consolidated CSV and creates each DL in your cloud tenant, with throttle awareness, member addition, manager assignment, and per-row error logging.

<#
.SYNOPSIS
    Bulk-creates Exchange Online distribution lists from a CSV.
.DESCRIPTION
    Reads a CSV produced by Export-DL-RecreateCsv.ps1 (or compatible),
    creates each DL, adds members, sets owners, logs results.
.PARAMETER CsvPath
    Path to the input CSV.
.PARAMETER LogPath
    Optional. Path for the transcript log.
.PARAMETER ThrottleSeconds
    Optional. Seconds to wait between operations. Default is 2.
.EXAMPLE
    .\New-BulkDistributionGroups.ps1 -CsvPath C:\DLExports\_consolidated.csv
#>
[CmdletBinding()]
param(
    [Parameter(Mandatory)]
    [ValidateScript({ Test-Path $_ })]
    [string]$CsvPath,

    [string]$LogPath = ".\dl-creation-$(Get-Date -Format 'yyyyMMdd-HHmmss').log",

    [int]$ThrottleSeconds = 2
)

try {
    Connect-ExchangeOnline -ShowBanner:$false -ErrorAction Stop
} catch {
    Write-Error "Failed to connect to Exchange Online: $($_.Exception.Message)"
    exit 1
}

Start-Transcript -Path $LogPath -Append | Out-Null

$dls = Import-Csv -Path $CsvPath
$successCount = 0
$failCount = 0

foreach ($dl in $dls) {
    Write-Host "`nProcessing: $($dl.DisplayName)" -ForegroundColor Cyan

    # Skip if a DL with this primary address already exists.
    $existing = Get-DistributionGroup -Identity $dl.PrimarySmtpAddress -ErrorAction SilentlyContinue
    if ($existing) {
        Write-Host "  Skipping — already exists: $($dl.PrimarySmtpAddress)" -ForegroundColor Yellow
        continue
    }

    try {
        $createParams = @{
            Name               = $dl.Alias
            DisplayName        = $dl.DisplayName
            PrimarySmtpAddress = $dl.PrimarySmtpAddress
            ErrorAction        = 'Stop'
        }

        # Map MailSecurityGroup to the right type.
        if ($dl.GroupType -eq "MailSecurityGroup") {
            $createParams['Type'] = 'Security'
        }

        New-DistributionGroup @createParams | Out-Null
        Write-Host "  Created" -ForegroundColor Green
        Start-Sleep -Seconds $ThrottleSeconds

        # Add members (semicolon-separated).
        if ($dl.Members) {
            $members = $dl.Members -split ';' | ForEach-Object { $_.Trim() } | Where-Object { $_ }
            foreach ($member in $members) {
                try {
                    Add-DistributionGroupMember `
                        -Identity $dl.PrimarySmtpAddress `
                        -Member $member `
                        -ErrorAction Stop
                    Write-Host "    + $member" -ForegroundColor DarkGreen
                } catch {
                    Write-Host "    ! Failed to add ${member}: $($_.Exception.Message)" -ForegroundColor Red
                }
            }
        }

        # Set owner(s).
        if ($dl.Owner) {
            $managers = $dl.Owner -split ';' | ForEach-Object { $_.Trim() } | Where-Object { $_ }
            try {
                Set-DistributionGroup `
                    -Identity $dl.PrimarySmtpAddress `
                    -ManagedBy $managers `
                    -ErrorAction Stop
                Write-Host "  Owner(s) set: $($managers -join ', ')" -ForegroundColor DarkGreen
            } catch {
                Write-Host "  ! Failed to set ManagedBy: $($_.Exception.Message)" -ForegroundColor Red
            }
        }

        # Add secondary SMTP addresses.
        if ($dl.SecondaryEmails) {
            $secondaries = $dl.SecondaryEmails -split ';' | ForEach-Object { $_.Trim() } | Where-Object { $_ }
            foreach ($s in $secondaries) {
                try {
                    Set-DistributionGroup `
                        -Identity $dl.PrimarySmtpAddress `
                        -EmailAddresses @{ Add = "smtp:$s" } `
                        -ErrorAction Stop
                } catch {
                    Write-Host "  ! Failed to add secondary $s: $($_.Exception.Message)" -ForegroundColor Red
                }
            }
        }

        # Set description.
        if ($dl.Description) {
            try {
                Set-DistributionGroup -Identity $dl.PrimarySmtpAddress -Notes $dl.Description -ErrorAction Stop
            } catch {
                Write-Host "  ! Failed to set description: $($_.Exception.Message)" -ForegroundColor Red
            }
        }

        $successCount++
    } catch {
        Write-Host "  FAILED: $($_.Exception.Message)" -ForegroundColor Red
        $failCount++
    }

    Start-Sleep -Seconds $ThrottleSeconds
}

Write-Host "`n=== Summary ===" -ForegroundColor Cyan
Write-Host "Created: $successCount" -ForegroundColor Green
Write-Host "Failed:  $failCount" -ForegroundColor Red
Write-Host "Log:     $LogPath" -ForegroundColor Gray

Stop-Transcript | Out-Null
Disconnect-ExchangeOnline -Confirm:$false

Common errors and how to fix them

These are the failures I have hit most often in real hybrid migrations. Recognising them quickly saves a lot of time.

Ambiguous recipient: a user has both an on-prem mail contact and a cloud mailbox

This is the gotcha that costs people the most time and the one almost no other guide covers.

The scenario: during a hybrid migration, you may end up with a user that exists in two forms — a mail-enabled contact in on-prem AD that points to their old external address, and a synced user with an Exchange Online mailbox at the new address. Both objects technically resolve to the same person, but they are separate recipients with different SMTP addresses.

When Add-DistributionGroupMember receives an email address or display name that matches both, you get one of these errors:

  • The operation couldn't be performed because 'jane.doe@yourdomain.com' matches multiple entries.
  • The recipient ... is ambiguous.
  • Or worse — silent failure where the script adds the wrong object (usually the contact) and you only notice when users start complaining about missing mail.

The fix: never feed the script a display name or alias for membership — always use the PrimarySmtpAddress of the specific object you want, and verify which one. Run this before you migrate:

Get-Recipient -Identity "jane.doe@yourdomain.com" -ResultSize Unlimited |
    Select-Object Name, RecipientTypeDetails, PrimarySmtpAddress, Identity

If you see two rows — one MailContact and one UserMailbox — you have a conflict. Decide which one belongs in the DL going forward and use that exact PrimarySmtpAddress in your CSV. Usually the cloud UserMailbox is the right answer, but in some lifecycle scenarios (offboarded user kept as a contact for forwarding, contractor with external mail) the contact is intentional.

The export script already prefers PrimarySmtpAddress when resolving members, so the CSV it produces is mostly safe. But if you edit CSVs by hand, by display name, or paste from a spreadsheet, the conflict can sneak back in.

A sanity-check pass before the bulk-create run is worth doing:

$members = (Import-Csv .\_consolidated.csv).Members -split ';' |
    ForEach-Object { $_.Trim() } |
    Where-Object { $_ } |
    Sort-Object -Unique

foreach ($m in $members) {
    $matches = Get-Recipient -Identity $m -ResultSize Unlimited -ErrorAction SilentlyContinue
    if (@($matches).Count -gt 1) {
        Write-Warning "Ambiguous: $m matches $((@($matches)).Count) recipients"
        $matches | Select-Object Name, RecipientTypeDetails, PrimarySmtpAddress |
            Format-Table -AutoSize
    }
}

Run this in the cloud tenant before the import. It tells you exactly which addresses are ambiguous and which recipient types are colliding so you can fix the CSV before you have a half-broken DL in production.

”The proxy address SMTP:… is already being used”

Something else in the tenant — a user mailbox, shared mailbox, another group, or a contact — already has that address as its primary or as a secondary proxy. Find the conflict:

Get-Recipient -Filter "EmailAddresses -like 'smtp:conflicting@yourdomain.com'"

Either change the planned address for the new DL, or remove the proxy address from the existing object if it is no longer needed. In hybrid migrations this often happens because Azure AD Connect synced the on-prem DL into the cloud as a mail-enabled object and you are now trying to recreate it natively.

”Couldn’t find object” when adding a member

The user listed in the CSV is misspelled, has not finished provisioning yet (common during a migration window), or is a guest user that has not accepted the invitation. The script logs the specific user that failed so you can fix the CSV and re-run only those rows.

”Microsoft.Exchange.Configuration.Tasks.ManagementObjectNotFoundException”

Usually means Exchange Online has not finished propagating the group you just created when the script tries to add members. The 2-second sleep after New-DistributionGroup handles most cases. If you still see it, increase -ThrottleSeconds to 5 or higher.

Throttling: random failures with vague messages, or “Too many requests”

Exchange Online aggressively throttles bulk operations. Three things help:

  1. Increase the sleep between operations (-ThrottleSeconds 5 or higher).
  2. Process in batches of 50 or fewer per session — disconnect and reconnect between batches.
  3. Run during off-hours. Throttling thresholds are tighter during peak business hours.

”Operation could not be performed because object … could not be found” on Set-DistributionGroup -ManagedBy

Means the user listed as owner does not have a mailbox. Mail-enabled users and mail contacts can sometimes work; cloud-only accounts without a license usually cannot. Verify with Get-Mailbox -Identity owner@yourdomain.com.

Synced groups blocking native re-creation

If your on-prem DL is being synced to the cloud by Azure AD Connect, the cloud has a read-only copy. You cannot create a new native cloud DL with the same address until the synced object is removed. Two paths forward:

  1. Stop syncing the group (move it out of the sync OU, or filter it out in AAD Connect), wait for the deletion to replicate to the cloud, then create the native version.
  2. Keep the synced group and migrate later as a managed exercise. Pick whichever fits your migration plan.

This is a per-DL decision and is the most common reason a “simple” DL migration takes longer than expected.

Verifying the results

After the bulk-create finishes, sanity-check what got created:

Get-DistributionGroup -ResultSize Unlimited |
    Where-Object { $_.WhenCreated -gt (Get-Date).AddHours(-1) } |
    Select-Object Name, PrimarySmtpAddress, ManagedBy, WhenCreated |
    Export-Csv .\dl-verification.csv -NoTypeInformation

That exports every DL created in the last hour. Diff against your input CSV. Any rows that did not appear need investigating — the transcript log will tell you why.

To verify members:

Get-DistributionGroup -ResultSize Unlimited |
    Where-Object { $_.WhenCreated -gt (Get-Date).AddHours(-1) } |
    ForEach-Object {
        $group = $_
        Get-DistributionGroupMember -Identity $group.PrimarySmtpAddress |
            Select-Object @{N='Group';E={$group.Name}}, Name, PrimarySmtpAddress
    } | Export-Csv .\dl-membership.csv -NoTypeInformation

Cleanup if something went wrong

If the run produced groups you need to remove, do not click through them in the admin centre one at a time. Use a CSV of the addresses to remove:

$toRemove = Import-Csv .\groups-to-remove.csv
foreach ($row in $toRemove) {
    Remove-DistributionGroup -Identity $row.PrimarySmtpAddress -Confirm:$false
    Start-Sleep -Seconds 2
}

Always export the current state first if you are removing anything in production:

Get-DistributionGroup -ResultSize Unlimited |
    Export-Clixml .\dl-backup-$(Get-Date -Format 'yyyyMMdd').xml

Wrapping up

The two-script export-then-import pattern turns a multi-day clicking exercise into something you can run, audit, and re-run in minutes. The pattern is also reusable — the same shape works for migrating shared mailboxes, mail contacts, and other recipient types between tenants or from on-prem to cloud.

A few habits that pay off if you do this kind of work often:

  • Always export to per-object files first, then consolidate. It gives you a paper trail per group.
  • Always verify ambiguous recipients before bulk-creating. The on-prem contact + cloud mailbox conflict is a silent killer in hybrid environments.
  • Always assume Exchange Online will throttle you. Build it in from the start.
  • Always start with a transcript log. When something goes sideways, you want a record.

If you ran into a different error or have an improvement to either script, I would genuinely like to hear about it. More posts on hybrid identity and Exchange migration coming soon.