Microsoft 365 licence and disabled service plan report
Export every licensed user with their usage location, each licence they hold and the service plans switched off in it, so you can re-create the same licensing in a target tenant or check nobody loses a service during a migration.
What you need
Modules and tools
Admin roles
- An account that can consent to (or has been granted) the Graph scopes User.Read.All and Organization.Read.All
Runs on
- PowerShell 7 (recommended by Microsoft for the SDK) or Windows PowerShell 5.1 on Windows or macOS
Options you can set
| Parameter | What it does |
|---|---|
-OutputPath | Optional. Where to save the CSV. Default: LicenceReport_yyyyMMdd_HHmm.csv in the current folder. |
-IncludeUnlicensed | Optional switch. Also list users with no licence (one row each, with blank licence columns). |
Step by step
- 1
Step 1
Install the Microsoft Graph PowerShell SDK.
- 2
Step 2
Save the script as
Get-CMLicenceReport.ps1in a working folder. - 3
Step 3
Open PowerShell 7 and change to that folder, then run
./Get-CMLicenceReport.ps1. - 4
Step 4
Sign in and accept the User.Read.All and Organization.Read.All permissions if asked.
- 5
Step 5
Open the CSV. Filter DisabledPlans to find users with services switched off, and UsageLocation for users with none set (Microsoft says you cannot assign a licence without one).
What you get: A UTF-8 CSV with one row per user per licence: UserPrincipalName, DisplayName, AccountEnabled, UsageLocation, SkuPartNumber, SkuId, DisabledPlans (service plan names, separated by semicolons) and DisabledPlanIds.
Get-CMLicenceReport.ps1
<#
.SYNOPSIS
Get-CMLicenceReport.ps1
.DESCRIPTION
Lists users with assigned licences, their usage location and the service plans
disabled in each licence, and exports it to CSV. Read-only: it changes nothing.
Licence and plan IDs are turned into names with Get-MgSubscribedSku, which returns
each subscription's SkuPartNumber and its ServicePlans (ServicePlanId, ServicePlanName).
Provided by cloud-migrations.au as is; test before use.
Read the script before you run it and try it in a test tenant first.
.NOTES
Requirements
- Microsoft Graph PowerShell SDK: Install-Module Microsoft.Graph -Scope CurrentUser
- Graph scopes User.Read.All and Organization.Read.All
Commands used (documented by Microsoft):
Connect-MgGraph, Get-MgSubscribedSku, Get-MgUser -Property, Disconnect-MgGraph
.EXAMPLE
./Get-CMLicenceReport.ps1
.EXAMPLE
./Get-CMLicenceReport.ps1 -IncludeUnlicensed -OutputPath C:\Reports\licences.csv
#>
[CmdletBinding()]
param(
[string]$OutputPath = (Join-Path -Path (Get-Location) -ChildPath ('LicenceReport_{0}.csv' -f (Get-Date -Format 'yyyyMMdd_HHmm'))),
[switch]$IncludeUnlicensed
)
$ErrorActionPreference = 'Stop'
# 1. Connect
try {
Write-Host 'Connecting to Microsoft Graph...'
Connect-MgGraph -Scopes 'User.Read.All', 'Organization.Read.All'
}
catch {
Write-Error "Could not connect to Microsoft Graph: $($_.Exception.Message)"
return
}
try {
# 2. Build look-up tables: SkuId -> SkuPartNumber, ServicePlanId -> ServicePlanName
Write-Progress -Activity 'Licence report' -Status 'Reading subscriptions' -PercentComplete 5
$skuNames = @{}
$planNames = @{}
foreach ($sku in @(Get-MgSubscribedSku -All)) {
$skuNames["$($sku.SkuId)"] = $sku.SkuPartNumber
foreach ($plan in @($sku.ServicePlans)) { $planNames["$($plan.ServicePlanId)"] = $plan.ServicePlanName }
}
# 3. Users with the properties we need
Write-Progress -Activity 'Licence report' -Status 'Reading users (this can take a while)' -PercentComplete 15
$users = @(Get-MgUser -All -Property Id, DisplayName, UserPrincipalName, AccountEnabled, UsageLocation, AssignedLicenses)
Write-Host ("Read {0} users." -f $users.Count)
$results = New-Object System.Collections.Generic.List[object]
$i = 0
foreach ($u in $users) {
$i++
if ($i % 100 -eq 0 -or $i -eq $users.Count) {
Write-Progress -Activity 'Licence report' -Status ("User {0} of {1}" -f $i, $users.Count) -PercentComplete (15 + (($i / [math]::Max($users.Count, 1)) * 85))
}
$licences = @($u.AssignedLicenses)
if ($licences.Count -eq 0) {
if ($IncludeUnlicensed) {
$results.Add([pscustomobject][ordered]@{
UserPrincipalName = $u.UserPrincipalName; DisplayName = $u.DisplayName; AccountEnabled = $u.AccountEnabled
UsageLocation = $u.UsageLocation; SkuPartNumber = ''; SkuId = ''; DisabledPlans = ''; DisabledPlanIds = ''
})
}
continue
}
foreach ($l in $licences) {
$ids = @($l.DisabledPlans | ForEach-Object { "$_" })
$names = @($ids | ForEach-Object { if ($planNames.ContainsKey($_)) { $planNames[$_] } else { $_ } })
$results.Add([pscustomobject][ordered]@{
UserPrincipalName = $u.UserPrincipalName
DisplayName = $u.DisplayName
AccountEnabled = $u.AccountEnabled
UsageLocation = $u.UsageLocation
SkuPartNumber = $(if ($skuNames.ContainsKey("$($l.SkuId)")) { $skuNames["$($l.SkuId)"] } else { '' })
SkuId = "$($l.SkuId)"
DisabledPlans = ($names -join ';')
DisabledPlanIds = ($ids -join ';')
})
}
}
Write-Progress -Activity 'Licence report' -Completed
# 4. Export
$results | Export-Csv -Path $OutputPath -NoTypeInformation -Encoding UTF8
$noLocation = @($users | Where-Object { @($_.AssignedLicenses).Count -gt 0 -and -not $_.UsageLocation }).Count
Write-Host ("Done. {0} rows written. Licensed users with no usage location: {1}" -f $results.Count, $noLocation)
Write-Host "Report saved to: $((Resolve-Path -Path $OutputPath).Path)"
}
catch {
Write-Error "Licence report failed: $($_.Exception.Message)"
}
finally {
Disconnect-MgGraph | Out-Null
}Commands documented here
Every command in the script is documented on these vendor pages. Test in a non-production tenant or environment first.