Retention policies decide how long Microsoft 365 keeps email, files, Teams chats and Copilot conversations, and when it deletes them. They are easy to set up once and forget. Then someone asks what you keep and for how long, and the honest answer is “I’d have to check.” Here is how to check.
Where retention settings live
Microsoft 365 keeps retention settings in more than one place, which is why nobody remembers them all.
- Purview retention policies. The main tool. Each one covers whole locations, such as all Exchange mailboxes, some SharePoint sites, Teams chats, or Copilot.
- Retention label policies. These publish labels that people apply to individual emails and files, or apply labels automatically. They show up in the same PowerShell list as retention policies.
- Exchange retention policies. The older, mailbox-only kind, also called messaging records management. Exchange Online gives every new mailbox the Default MRM Policy, which moves items older than two years into the archive mailbox when archiving is turned on.
- Holds. Litigation Hold and eDiscovery holds keep content no matter what a retention policy says. I covered those in How to List Every Mailbox on Litigation Hold.
What you need
- An account with the View-Only Retention Management role. The Global Reader role includes it, so you don’t need to be a Compliance Administrator just to look. Microsoft Learn lists the roles.
- For the portal, any web browser.
- For the full report, a Windows PC with PowerShell 7 and the ExchangeOnlineManagement module. This part needs Windows. The retention cmdlets live in Security & Compliance PowerShell, and Microsoft says it isn’t available in PowerShell 7 on Linux or macOS. I normally run my scripts on Ubuntu, so this one is the exception.
Install-Module ExchangeOnlineManagement -Scope CurrentUser
Check in the Purview portal
This is the quickest way to see what you have, and it works from any computer.
- Sign in to the Microsoft Purview portal.
- Go to Solutions > Data Lifecycle Management > Policies > Retention policies.
- Select each policy. The pane that opens shows the locations it covers, what it keeps or deletes and for how long, and whether it has finished applying.
- Check Retention labels and Label policies under Data Lifecycle Management too, if anyone in your organization uses labels.
To see which policies apply to one person, site or group, use Policy lookup in the same area. It needs the exact email address or site URL.
Check with PowerShell
The portal is fine for a handful of policies. For a list you can save, compare later, or hand to an auditor, this script reads every retention policy in the tenant and writes one row per policy to a CSV file. It changes nothing.
#Requires -Modules ExchangeOnlineManagement
<#
.SYNOPSIS
Lists every Microsoft Purview retention policy in a Microsoft 365 tenant: what each one
covers, what it does, and whether anything covers Copilot. Read-only.
.DESCRIPTION
Connects to Security & Compliance PowerShell and reads both families of retention policy:
the older ones (Get-RetentionCompliancePolicy, which also returns retention label policies)
and the newer app policies (Get-AppRetentionCompliancePolicy, used for Teams chats, Copilot
and other AI apps, and Viva Engage). It reads each policy's rule, prints a summary, and saves
the details to a CSV file. It changes nothing in the tenant.
Security & Compliance PowerShell works only on Windows. Microsoft doesn't offer it in
PowerShell 7 on Linux or macOS.
.PARAMETER UserPrincipalName
The account to sign in with, for example admin@contoso.com. Optional.
.PARAMETER OutputPath
Where to save the CSV. Defaults to RetentionPolicies.csv in the current folder.
.EXAMPLE
.\Get-RetentionPolicyReport.ps1 -UserPrincipalName admin@contoso.com
.NOTES
Needs the ExchangeOnlineManagement module (3.2.0 or later) and an account with the
View-Only Retention Management role. The Global Reader role group includes it.
#>
[CmdletBinding()]
param(
[string]$UserPrincipalName,
[string]$OutputPath = './RetentionPolicies.csv'
)
# Retention actions as a rule stores them, in plain words.
$actionText = @{
Keep = 'Retain'
KeepAndDelete = 'Retain, then delete'
Delete = 'Delete'
}
# Location property names, minus "Location", in the words the Purview portal uses.
$locationText = @{
Exchange = 'Exchange mailboxes'
SharePoint = 'SharePoint sites'
OneDrive = 'OneDrive accounts'
ModernGroup = 'Microsoft 365 Groups'
PublicFolder = 'Exchange public folders'
Skype = 'Skype for Business'
TeamsChat = 'Teams chats'
TeamsChannel = 'Teams channel messages'
AdaptiveScope = 'Adaptive scopes'
}
function Format-Duration {
# 2555 becomes "7 years", 30 becomes "30 days".
param([int]$Days)
if ($Days -ge 365 -and $Days % 365 -eq 0) {
$years = $Days / 365
if ($years -eq 1) { return '1 year' }
return "$years years"
}
if ($Days -eq 1) { return '1 day' }
return "$Days days"
}
function Get-LocationText {
# Lists what a policy covers, for example "Exchange: All; OneDrive: 3 selected; Apps: User:M365Copilot".
param($Policy)
$parts = @(foreach ($property in $Policy.PSObject.Properties) {
if ($property.Name -notlike '*Location') { continue }
$values = @($property.Value | Where-Object { $_ })
if ($values.Count -eq 0) { continue }
$names = @(foreach ($value in $values) {
if ($value.PSObject.Properties['Name'] -and $value.Name) { $value.Name } else { "$value" }
})
$label = $property.Name -replace 'Location$', ''
if ($locationText[$label]) { $label = $locationText[$label] }
if ($names -contains 'All') { "${label}: All" } else { "${label}: $($names.Count) selected" }
})
$apps = @($Policy.Applications | Where-Object { $_ })
if ($apps.Count -gt 0) { $parts += "Apps: $($apps -join ', ')" }
$parts -join '; '
}
function Get-RuleText {
# Describes what one rule does, for example "Retain for 7 years, then delete (counted from creation)".
param($Rule)
if ($Rule.PublishComplianceTag) { return "Publishes retention label: $($Rule.PublishComplianceTag)" }
if ($Rule.ApplyComplianceTag) { return "Auto-applies retention label: $($Rule.ApplyComplianceTag)" }
$action = "$($Rule.RetentionComplianceAction)"
$duration = "$($Rule.RetentionDuration)"
if ($duration -match '^\d+$') {
$period = Format-Duration -Days ([int]$duration)
$text = switch ($action) {
'Keep' { "Retain for $period" }
'KeepAndDelete' { "Retain for $period, then delete" }
'Delete' { "Delete after $period" }
default { "$action, $period" }
}
}
elseif ($action -eq 'Keep' -and ($duration -eq 'Unlimited' -or -not $duration)) {
$text = 'Retain forever'
}
else {
$text = (@($actionText[$action], $action, 'Unknown action') | Where-Object { $_ })[0]
if ($duration) { $text += ", $duration" }
}
switch ("$($Rule.ExpirationDateOption)") {
'CreationAgeInDays' { $text += ' (counted from creation)' }
'ModificationAgeInDays' { $text += ' (counted from last change)' }
}
$text
}
function ConvertTo-ReportRow {
# Turns one policy and its rules into one row of the report.
param($Policy, $Rules, [switch]$AppPolicy)
$rules = @($Rules | Where-Object { $_ })
$apps = @($Policy.Applications | Where-Object { $_ })
$olderTeamsChat = -not $AppPolicy -and @($Policy.TeamsChatLocation | Where-Object { $_ }).Count -gt 0
$type = 'Retention policy'
if (@($rules | Where-Object { $_.PublishComplianceTag }).Count -gt 0) { $type = 'Retention label policy (published)' }
elseif (@($rules | Where-Object { $_.ApplyComplianceTag }).Count -gt 0) { $type = 'Retention label policy (auto-apply)' }
$notes = @()
if ("$($Policy.Enabled)" -eq 'False') { $notes += 'Turned off.' }
if ($Policy.Mode -and "$($Policy.Mode)" -ne 'Enforce') { $notes += "Mode is $($Policy.Mode), not Enforce." }
if ("$($Policy.RestrictiveRetention)" -eq 'True') { $notes += "Preservation Lock is on, so it can't be turned off or shortened." }
if ($olderTeamsChat) {
$notes += "Older Teams chats policy. Microsoft treats these as Teams-only, so don't count on it for Copilot."
}
$does = 'No rule found'
if ($rules.Count -gt 0) { $does = (@($rules | ForEach-Object { Get-RuleText -Rule $_ }) -join ' | ') }
[pscustomobject]@{
Name = $Policy.Name
Type = $type
Enabled = $Policy.Enabled
Status = $Policy.DistributionStatus
Covers = Get-LocationText -Policy $Policy
Does = $does
CoversCopilot = @($apps | Where-Object { $_ -match 'Copilot' }).Count -gt 0
OlderTeamsChat = $olderTeamsChat
Notes = $notes -join ' '
}
}
if ($IsLinux -or $IsMacOS) {
Write-Warning "Microsoft doesn't offer Security & Compliance PowerShell on Linux or macOS, so the connection will probably fail. Run this on Windows, or check the policies in the Purview portal."
}
$connect = @{ ShowBanner = $false; ErrorAction = 'Stop' }
if ($UserPrincipalName) { $connect.UserPrincipalName = $UserPrincipalName }
Connect-IPPSSession @connect
try {
$report = [System.Collections.Generic.List[object]]::new()
# Older retention policies and retention label policies. Locations are accurate only with
# -DistributionDetail, which Microsoft documents one policy at a time.
foreach ($summary in @(Get-RetentionCompliancePolicy -ErrorAction Stop)) {
try {
$policy = Get-RetentionCompliancePolicy -Identity $summary.Name -DistributionDetail -ErrorAction Stop
$rules = Get-RetentionComplianceRule -Policy $summary.Name -ErrorAction Stop
$report.Add((ConvertTo-ReportRow -Policy $policy -Rules $rules))
}
catch {
Write-Warning "Couldn't read the policy '$($summary.Name)': $($_.Exception.Message)"
}
}
# Newer app policies: Teams chats, Copilot and AI apps, Viva Engage, Teams call logs.
try {
$appPolicies = @(Get-AppRetentionCompliancePolicy -ErrorAction Stop)
}
catch {
Write-Warning "Couldn't list app retention policies: $($_.Exception.Message)"
$appPolicies = @()
}
foreach ($summary in $appPolicies) {
if ($report.Name -contains $summary.Name) { continue }
try {
$policy = Get-AppRetentionCompliancePolicy -Identity $summary.Name -DistributionDetail -ErrorAction Stop
$rules = Get-AppRetentionComplianceRule -Policy $summary.Name -ErrorAction Stop
$report.Add((ConvertTo-ReportRow -Policy $policy -Rules $rules -AppPolicy))
}
catch {
Write-Warning "Couldn't read the policy '$($summary.Name)': $($_.Exception.Message)"
}
}
}
finally {
Disconnect-ExchangeOnline -Confirm:$false -ErrorAction SilentlyContinue | Out-Null
}
if ($report.Count -eq 0) {
Write-Host 'No retention policies were found in this tenant.'
return
}
$report | Export-Csv -Path $OutputPath -NoTypeInformation -Encoding utf8
$report | Format-Table Name, Type, Status, Does -Wrap | Out-String -Width 200 | Write-Host
Write-Host "Found $($report.Count) policies. Full details, including what each one covers, are in $OutputPath"
# Last, say whether anything covers Copilot. Older Teams chats policies don't count.
$copilot = @($report | Where-Object CoversCopilot)
$olderTeams = @($report | Where-Object OlderTeamsChat)
if ($copilot.Count -gt 0) {
Write-Host "Copilot prompts and responses are covered by: $($copilot.Name -join ', ')"
}
elseif ($olderTeams.Count -gt 0) {
Write-Host "No policy covers Copilot prompts and responses. Older Teams chats policies ($($olderTeams.Name -join ', ')) don't count, because Microsoft treats them as Teams-only."
}
else {
Write-Host 'No retention policy covers Copilot prompts and responses.'
}
Save it as Get-RetentionPolicyReport.ps1 and run it on Windows with your admin account. A browser window opens for you to sign in.
.\Get-RetentionPolicyReport.ps1 -UserPrincipalName admin@contoso.com
What it does
- Reads two lists of policies, not one. Microsoft is moving retention to a newer set of cmdlets, so policies for Teams chats, Copilot and other AI apps, and Viva Engage come from
Get-AppRetentionCompliancePolicy, while policies for Exchange, SharePoint and OneDrive, and label policies, come fromGet-RetentionCompliancePolicy. Check only one and you miss part of the picture. - Reads each policy with
-DistributionDetail. Without it, Microsoft says, the location values “will be inaccurate.” - Describes each rule in plain words, such as “Retain for 7 years, then delete (counted from creation).”
- Notes any policy that is turned off, not enforced, or under Preservation Lock, which means no one can turn it off or shorten it.
- Saves everything to RetentionPolicies.csv, then tells you whether anything covers Copilot.
Exchange’s own retention policies
The older Exchange retention policies live in Exchange Online PowerShell, and that one does run on Linux. On a machine without a browser, add -Device to the connect command and sign in from another computer. Microsoft Learn explains the options.
Connect-ExchangeOnline -ShowBanner:$false
# Exchange retention policies, and the tags each one uses
Get-RetentionPolicy | Format-List Name, RetentionPolicyTagLinks
# What each tag does
Get-RetentionPolicyTag | Format-Table Name, Type, AgeLimitForRetention, RetentionAction
# How many mailboxes use each policy
Get-Mailbox -ResultSize Unlimited | Group-Object RetentionPolicy -NoElement | Sort-Object Count -Descending
# Organization-wide Purview retention policies that reach Exchange
Get-OrganizationConfig | Select-Object -ExpandProperty InPlaceHolds
Disconnect-ExchangeOnline -Confirm:$false
The last command lists organization-wide Purview retention policies that reach Exchange. Entries that start with mbx cover mailboxes and older Teams chats policies, and entries that start with grp cover Microsoft 365 Groups and Teams channel messages. The number after the colon says what the policy does: 1 deletes or publishes labels, 2 retains, and 3 retains and then deletes. You get an ID, not a name, so match it up in the portal. Policies for Copilot and the other newer locations don’t show up here at all, which is one more reason to use the portal or the script.
Things to know
- When policies disagree, retention wins over deletion and the longest retention period wins. A short delete policy doesn’t override a longer one that keeps the same content.
- A new or changed policy can take up to seven days to apply.
- Retention policies come with Microsoft 365 Business Premium, E3 and E5, among others. The service description has the full list.
- The script only reads settings. It doesn’t change any policy, and it disconnects when it finishes.
Once you know what you have, write it down: what each policy covers, how long it keeps things, and why. The next time someone asks, the answer takes a minute instead of an afternoon.