Microsoft 365: 10 PowerShell commands to find unused licenses

Correlate inventory, disabled accounts, direct or group assignment, errors, and activity with ten explained collections before removing any license.

01

No recent activity does not automatically mean an unused license

A license may protect a retained mailbox, enable a rarely used app, support a leave of absence, room, shared device, or compliance requirement. Conversely, a disabled account can still consume several products. A defensible decision correlates the assignment, its source, available activity, owner, and business dependencies.

The ten collections below are read-only. They produce review candidates, not authorization to remove licenses. All identities and domains are fictional; use a protected administration workstation and controlled output folder.

02

Prepare Microsoft Graph with least-privileged permissions

User.Read.All reads users and their licenses, Organization.Read.All subscriptions, Group.Read.All groups, Reports.Read.All usage reports, and AuditLog.Read.All signInActivity. That last property also requires Microsoft Entra ID P1 or P2.

Install Microsoft.Graph modules under your workstation-management process. Start without LicenseAssignment.ReadWrite.All: none of the ten diagnostics needs it. The operator's Entra role must also authorize the reads.

Read-only connection
$scopes = @(
  'User.Read.All'
  'Organization.Read.All'
  'Group.Read.All'
  'Reports.Read.All'
  'AuditLog.Read.All'
)
Connect-MgGraph -Scopes $scopes -NoWelcome
Get-MgContext | Select-Object TenantId,Account,AuthType,Scopes
03

1. Measure available capacity by SKU

SkuPartNumber is more readable than SkuId but is not a guaranteed commercial product name. Active, warning, and consumed units show pressure on a SKU; they do not identify who can safely lose it.

Command 1 — subscriptions
Get-MgSubscribedSku -All | ForEach-Object {
  [pscustomobject]@{
    SkuPartNumber = $_.SkuPartNumber
    SkuId         = $_.SkuId
    Active        = $_.PrepaidUnits.Enabled
    Warning       = $_.PrepaidUnits.Warning
    Consumed      = $_.ConsumedUnits
    Available     = $_.PrepaidUnits.Enabled - $_.PrepaidUnits.Warning - $_.ConsumedUnits
  }
} | Sort-Object SkuPartNumber
04

2. Inventory every licensed member

The advanced filter requires ConsistencyLevel eventual. Excluding guests reduces noise, but do not decide anything about synchronized, resource, or service identities without confirming their purpose.

Command 2 — licensed members
$licensedUsers = Get-MgUser -All   -Filter "assignedLicenses/$count ne 0 and userType eq 'Member'"   -ConsistencyLevel eventual   -Property Id,DisplayName,UserPrincipalName,AccountEnabled,UserType,AssignedLicenses

$licensedUsers | Select-Object DisplayName,UserPrincipalName,AccountEnabled,
  @{n='LicenseCount';e={$_.AssignedLicenses.Count}}
05

3. Isolate disabled accounts that remain licensed

This is a review queue, never an automatic cleanup list. Confirm departure, mailbox retention, OneDrive, eDiscovery, applications, devices, and delegation.

Command 3 — disabled candidates
$disabledLicensed = $licensedUsers |
  Where-Object AccountEnabled -eq $false |
  Select-Object Id,DisplayName,UserPrincipalName,
    @{n='LicenseCount';e={$_.AssignedLicenses.Count}}

$disabledLicensed | Sort-Object UserPrincipalName
06

4. Translate SKU IDs into useful names

This join supports a discussion about a product instead of a GUID. One capability can come from several SKUs; do not count a feature as multiple recoverable licenses.

Command 4 — detail per account
$skuMap = @{}
Get-MgSubscribedSku -All | ForEach-Object { $skuMap[$_.SkuId.Guid] = $_.SkuPartNumber }

$licenseInventory = foreach ($user in $licensedUsers) {
  foreach ($license in $user.AssignedLicenses) {
    [pscustomobject]@{
      UserPrincipalName = $user.UserPrincipalName
      AccountEnabled    = $user.AccountEnabled
      SkuId             = $license.SkuId
      SkuPartNumber     = $skuMap[$license.SkuId.Guid]
    }
  }
}
$licenseInventory | Sort-Object SkuPartNumber,UserPrincipalName
07

5. Inspect a candidate's service plans

Get-MgUserLicenseDetail shows plans and their states. A disabled plan does not prove the whole SKU is redundant; compare remaining plans and required capabilities.

Command 5 — fictional user
$candidate = 'alex.tremblay@contoso.example'
Get-MgUserLicenseDetail -UserId $candidate -All |
  Select-Object SkuPartNumber,SkuId,
    @{n='ServicePlans';e={($_.ServicePlans | ForEach-Object {
      "$($_.ServicePlanName):$($_.ProvisioningStatus)"
    }) -join '; '}}
08

6. Separate direct and inherited assignments

An empty AssignedByGroup normally means direct assignment; a GUID identifies the source group. A user can receive the same SKU from multiple sources. Removing an inherited license directly does not fix the membership that will assign it again.

Command 6 — assignment source
$assignmentStates = foreach ($user in $licensedUsers) {
  $detail = Get-MgUser -UserId $user.Id -Property UserPrincipalName,LicenseAssignmentStates
  foreach ($state in $detail.LicenseAssignmentStates) {
    [pscustomobject]@{
      UserPrincipalName = $detail.UserPrincipalName
      SkuPartNumber     = $skuMap[$state.SkuId.Guid]
      Source            = if ($state.AssignedByGroup) { "Group: $($state.AssignedByGroup)" } else { 'Direct' }
      State             = $state.State
      Error             = $state.Error
    }
  }
}
$assignmentStates | Sort-Object UserPrincipalName,SkuPartNumber
09

7. Inventory groups that assign licenses

Group licensing simplifies governance, but an oversized group can waste seats. Nested groups do not pass licenses to nested members; validate direct members and the HR process that manages them.

Command 7 — licensed groups
$licensedGroups = Get-MgGroup -All -Property Id,DisplayName,GroupTypes,AssignedLicenses |
  Where-Object { $_.AssignedLicenses.Count -gt 0 }

$licensedGroups | ForEach-Object {
  [pscustomobject]@{
    GroupName    = $_.DisplayName
    GroupId      = $_.Id
    AssignedSkus = ($_.AssignedLicenses | ForEach-Object {
      $skuMap[$_.SkuId.Guid]
    }) -join ', '
  }
}
10

8. Find errors before optimizing

An error can indicate insufficient licenses, missing usage location, or incompatible plans. It is neither a recoverable license nor proof of inactivity; fix the cause or document the exception.

Command 8 — error states
$licenseErrors = $assignmentStates |
  Where-Object { $_.State -ne 'Active' -or $_.Error }

$licenseErrors |
  Select-Object UserPrincipalName,SkuPartNumber,Source,State,Error |
  Sort-Object SkuPartNumber,UserPrincipalName
11

9. Export available Microsoft 365 activity

The D180 report covers at most 180 days and fields vary by product. Last activity is intentional activity observed by a service, not a universal measure of value. Identities are concealed by default in many tenants; change that privacy setting only when governance permits it.

Command 9 — D180 activity
$reportPath = Join-Path $env:TEMP 'M365-ActiveUsers-D180.csv'
Get-MgReportOffice365ActiveUserDetail -Period D180 -OutFile $reportPath
$usage = Import-Csv $reportPath
$usage | Select-Object 'User Principal Name','Assigned Products',
  'Exchange Last Activity Date','OneDrive Last Activity Date',
  'SharePoint Last Activity Date','Teams Last Activity Date'
12

10. Compare the last successful Entra sign-in

LastSuccessfulSignInDateTime includes successful interactive and non-interactive sign-ins, but data is not backfilled before December 1, 2023. Blank can mean never used or unavailable data. The property requires Entra ID P1/P2 and AuditLog.Read.All, and updates can be delayed.

Command 10 — sign-in activity
$signIns = Get-MgUser -All   -Filter 'assignedLicenses/$count ne 0'   -ConsistencyLevel eventual   -Property Id,DisplayName,UserPrincipalName,AccountEnabled,SignInActivity

$signIns | Select-Object DisplayName,UserPrincipalName,AccountEnabled,
  @{n='LastSuccessfulSignInUtc';e={$_.SignInActivity.LastSuccessfulSignInDateTime}} |
  Sort-Object LastSuccessfulSignInUtc
13

Build a review queue, not a deletion robot

Export a timestamped inventory, then add owner, decision, rationale, and due date. Have each case approved by the business manager and retention or security owners. Lack of activity must never be the only criterion.

SignalCareful readingRequired validation
Disabled account + direct licenseHigh-priority candidateDeparture, retention, mailbox, OneDrive, apps
No D180 activityNo observed activity in that windowManager, seasonality, actual product use
Old last sign-inSupporting indicatorService identity, non-interactive clients, coverage
Inherited licenseFix the sourceGroup owner, rule, collective impact
Assignment errorProvisioning problemUsageLocation, capacity, plans, need
Controlled export
$stamp = Get-Date -Format 'yyyyMMdd-HHmmss'
$licenseInventory | Export-Csv ".\license-inventory-$stamp.csv" -NoTypeInformation -Encoding utf8
$assignmentStates | Export-Csv ".\license-sources-$stamp.csv" -NoTypeInformation -Encoding utf8
14

Pilot removal: explicit change and rollback

The next command changes the tenant and is not one of the ten collections. Run it only after confirming a direct assignment, Exchange, OneDrive, Teams and Purview dependencies, retention, and owner approval. For an inherited license, review group membership through its owning process.

Test one non-critical account, capture its SKUs, wait for propagation, and validate sign-in and services. Rollback reassigns the SKU if capacity and plans remain compatible; it does not guarantee recovery of data deleted by a lifecycle process.

CHANGE — remove one approved direct SKU
$userId = 'alex.tremblay@contoso.example'
$skuId  = [guid]'00000000-0000-0000-0000-000000000000'

# Capture before the approved change
$before = Get-MgUserLicenseDetail -UserId $userId -All
$before | Select-Object SkuPartNumber,SkuId |
  Export-Csv '.\before-license-change.csv' -NoTypeInformation

Set-MgUserLicense -UserId $userId -AddLicenses @() -RemoveLicenses @($skuId)
ROLLBACK — reassign the SKU
$addLicense = @{ SkuId = $skuId }
Set-MgUserLicense -UserId $userId -AddLicenses @($addLicense) -RemoveLicenses @()
15

What this workflow does not replace

These commands show where to focus review. They do not independently calculate contract cost, commitments, discounts, add-ons, compliance obligations, or the correct target SKU. A Microsoft 365 assessment connects inventory, activity, identities, groups, retention, security, and contracts to produce a validated plan instead of mass removal.