Shore Up
a clerk copying names from a stack of separate group rosters onto one long ledger, then sealing that ledger in an envelope marked with a checkmark
WindowsMail

Bulk-Export Exchange Distribution List Membership for Compliance

Ketan Aagja8 min read
No ratings yet

An auditor asks the same question every year: who was in which distribution list on this date? Clicking through each group in the admin center does not scale past a handful of groups, and it produces nothing you can hand over. This guide scripts a clean, point-in-time CSV of every distribution group and its members.

Before you run this

This script reads your directory. It connects to Exchange Online, enumerates your distribution groups, expands each group's membership, and writes it all to one CSV file. It makes no changes — it creates nothing, deletes nothing, and modifies no group. The only thing it produces is a file on your workstation.

  • Privileges: You do not need Global Admin or anything destructive. You need an account with a read role over recipients — the built-in View-Only Recipients RBAC role (part of View-Only Organization Management) is enough. Run PowerShell as your normal user; there is no need for an elevated/Administrator session unless you are installing the module for all users (see below).
  • Test first: Read the script before you run it. Try it against a single group first with Get-DistributionGroupMember -Identity "one-test-group" so you can see the shape of the output before you loop over the whole tenant.
  • The output is sensitive. The CSV is a list of people and their email addresses — treat it as PII. Store it somewhere access-controlled, hand it to the auditor over an approved channel, and delete your working copy when you are done. That is the only "undo" this script needs, because nothing in your tenant changed.
  • Honesty about scope: Get-DistributionGroupMember returns direct members only. If a distribution group contains another group, you get one row for that nested group, not its expanded members. Dynamic distribution groups are handled separately at the end, because their membership is a query, not a stored list. Know both of these before you certify the output to a compliance officer.

Assumptions

I am writing this for Exchange Online (Microsoft 365), run from a Windows workstation with PowerShell 5.1 or PowerShell 7 and the ExchangeOnlineManagement V3 module. If you are on on-premises Exchange, the same Get-DistributionGroup / Get-DistributionGroupMember cmdlets exist in the Exchange Management Shell — you skip the Connect-ExchangeOnline step and run it from an Exchange server instead. Everything else below is the same.

Install and connect

If the module is not already present, install it for your user (this line is the only one that may want an elevated session, and only if you install for AllUsers):

Install-Module ExchangeOnlineManagement -Scope CurrentUser

Then connect. This opens a browser for modern auth and supports MFA:

# Replace with your admin UPN
Connect-ExchangeOnline -UserPrincipalName admin@example.com

The export script

Save this as Export-DLMembership.ps1, change the two placeholder paths, and run it. Comments mark the non-obvious lines.

# --- Output location: change to a real, access-controlled folder ---
$outFolder = "C:\path\to\compliance-exports"
$timestamp = Get-Date -Format 'yyyyMMdd-HHmmss'   # point-in-time stamp for the auditor
$outFile   = Join-Path $outFolder "DL-Membership-$timestamp.csv"

# Pull every distribution group once. -ResultSize Unlimited defeats the default page limit.
$groups = Get-DistributionGroup -ResultSize Unlimited

$results = foreach ($dl in $groups) {
    $members = Get-DistributionGroupMember -Identity $dl.Identity -ResultSize Unlimited

    if (-not $members) {
        # Record empty groups explicitly, so an auditor sees the group existed with no members.
        [pscustomobject]@{
            GroupName         = $dl.DisplayName
            GroupPrimarySMTP  = $dl.PrimarySmtpAddress
            GroupType         = $dl.RecipientTypeDetails
            MemberName        = '(no members)'
            MemberPrimarySMTP = ''
            MemberType        = ''
        }
    }
    else {
        foreach ($m in $members) {
            [pscustomobject]@{
                GroupName         = $dl.DisplayName
                GroupPrimarySMTP  = $dl.PrimarySmtpAddress
                GroupType         = $dl.RecipientTypeDetails
                MemberName        = $m.DisplayName
                MemberPrimarySMTP = $m.PrimarySmtpAddress
                MemberType        = $m.RecipientTypeDetails   # flags nested groups as a group type
            }
        }
    }
}

# UTF-8 keeps non-ASCII names intact; -NoTypeInformation keeps the header clean.
$results | Export-Csv -Path $outFile -NoTypeInformation -Encoding UTF8

Write-Host "Exported $($results.Count) membership rows across $($groups.Count) groups to $outFile"

The MemberType column is doing quiet compliance work: any row where it reads something like MailUniversalDistributionGroup or MailUniversalSecurityGroup is a nested group, telling you and the auditor that those members are one level deeper and not expanded here.

Dynamic distribution groups

Dynamic groups have no stored membership — they are a saved recipient filter evaluated at send time. To capture who they resolve to right now, evaluate the filter with Get-Recipient. This is the standard, documented technique:

$dynFile = Join-Path $outFolder "DynamicDL-Membership-$timestamp.csv"

$dynResults = foreach ($ddg in Get-DynamicDistributionGroup -ResultSize Unlimited) {
    # Resolve the group's own filter against the directory
    $members = Get-Recipient -RecipientPreviewFilter $ddg.RecipientFilter -ResultSize Unlimited
    foreach ($m in $members) {
        [pscustomobject]@{
            GroupName         = $ddg.DisplayName
            GroupPrimarySMTP  = $ddg.PrimarySmtpAddress
            MemberName        = $m.DisplayName
            MemberPrimarySMTP = $m.PrimarySmtpAddress
            MemberType        = $m.RecipientTypeDetails
        }
    }
}

$dynResults | Export-Csv -Path $dynFile -NoTypeInformation -Encoding UTF8

Note the caveat honestly in your report: a dynamic group's membership is as of the moment you ran this, not a stored fact you can reproduce later. Some dynamic groups also use a container scope (RecipientContainer) in addition to the filter; if yours do, check the Get-DynamicDistributionGroup reference on Microsoft Learn for how to include it, rather than assuming the filter alone is complete.

Verify it worked

Confirm the group count in the CSV matches what the tenant reports:

# How many distinct groups landed in the file...
(Import-Csv $outFile | Select-Object GroupPrimarySMTP -Unique).Count

# ...versus how many groups exist
(Get-DistributionGroup -ResultSize Unlimited).Count

Those two numbers should match. Then spot-check one group by eye against the admin center:

Import-Csv $outFile | Where-Object GroupName -eq 'Your Test Group' |
    Select-Object MemberName, MemberPrimarySMTP

Open the CSV in Excel and confirm the header row, that names with accents render correctly (that is what the UTF-8 encoding buys you), and that empty groups appear with (no members) rather than silently vanishing.

Cleanup — the only "undo" here

Nothing in Exchange changed, so there is nothing in the tenant to roll back. The one thing you created is a file full of personal data. When the audit is closed, delete your working copies:

Remove-Item $outFile        # and $dynFile, once the auditor has an approved copy

And disconnect your session so a stale connection is not left open:

Disconnect-ExchangeOnline -Confirm:$false

That is the whole job: one timestamped CSV per run, reproducible, and honest about its two edges — nested groups aren't expanded, and dynamic groups are a snapshot. Say both of those in the cover note you hand the auditor, and the export stands up.

Written by
Ketan Aagja

Runs enterprise networks and security for a living, and writes Shore Up to turn two decades of hands-on Linux, Windows and mail-server work into guides you can actually use.

More about the author →

Was this article helpful?

Tap a star — no sign-in needed.

Be the first to rate this article.

Automate User Offboarding in Active Directory with PowerShell

This script offboards one leaving user in a single pass: it disables their AD account, records and removes their group memberships (except the primary group), and moves the account into a disabled-users OU. A separate, clearly marked step sets mail forwarding on their mailbox. The point is a consistent, logged procedure so nothing gets missed and you can reconstruct exactly what changed.

9 min read

Automate New-User Onboarding in Active Directory

This guide builds a PowerShell script that onboards one new employee in four steps: it creates an Active Directory user account , adds them to security groups , provisions an on-premises Exchange mailbox , and creates their home folder on a file server and sets NTFS permissions . The purpose is to replace the error-prone click-through in Active Directory Users and Computers with one repeatable, reviewable run.

10 min read

Bulk-Set Out-of-Office Auto-Replies with Exchange PowerShell

This guide sets the Automatic Replies (out-of-office) configuration on multiple mailboxes at once, using Set-MailboxAutoReplyConfiguration in Exchange Online PowerShell. It turns auto-reply on (or schedules it), and writes the internal and external message text. It does not delete mail, move anything, or change mailbox permissions — but it does overwrite whatever auto-reply text and state each affected mailbox currently has, and there is no built-in "undo" that restores the previous message. If a user had their own carefully worded reply set, this replaces it. So capture the current state first (I show how below) and treat the change list as production data.

8 min read