Free script · PowerShell

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.

PowerShellRead-onlyUpdated 4 Nov 2026
Read-only: makes no changes
Before you run it

What you need

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
Parameters

Options you can set

ParameterWhat it does
-OutputPathOptional. Where to save the CSV. Default: LicenceReport_yyyyMMdd_HHmm.csv in the current folder.
-IncludeUnlicensedOptional switch. Also list users with no licence (one row each, with blank licence columns).
How to run it

Step by step

  1. 1
  2. 2

    Step 2

    Save the script as Get-CMLicenceReport.ps1 in a working folder.

  3. 3

    Step 3

    Open PowerShell 7 and change to that folder, then run ./Get-CMLicenceReport.ps1.

  4. 4

    Step 4

    Sign in and accept the User.Read.All and Organization.Read.All permissions if asked.

  5. 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.

The script

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
}
Sources

Commands documented here

Every command in the script is documented on these vendor pages. Test in a non-production tenant or environment first.