Get-SPOTermStoreAnalysis
This PnP PowerShell script runs a term store orientation and governance pass that answers four separate questions in one run: a full inventory of every term group, term set, and term count with each set's open/closed status, which sets are empty, which terms are missing a translation in one of the term store's configured languages, and which term sets don't appear to be referenced by a managed metadata column anywhere in the tenant. Results are exported as separate CSVs so each question can be worked independently.
Purpose
- Inventories every term group, term set, and term count, and records whether each set is open for ad hoc term creation or closed, a real governance signal — an open set with a large ad hoc term count is usually a sign nobody's taking care of it
- Flags term sets with zero terms so they can be reviewed for cleanup or completion
- For any term set using more than one of the term store's configured languages, checks every term for a label that exists in one language but is missing in another, directly relevant to multilingual search and navigation
- Cross-references every term set against managed metadata (Taxonomy) columns found across the tenant to flag term sets with no detected usage anywhere
- Excludes the built-in "Search Dictionaries" and "System" term groups, plus per-site "Site Collection - <site url>" groups, since these are system-provisioned and not part of the shared taxonomy
Scope
- Open/Closed detection relies on the
IsOpenForTermCreationproperty, which isn't guaranteed to be present on every PnP.PowerShell version — the script falls back to reporting "UNKNOWN" rather than failing outright; treat this as the part of the script most likely to need a small correction for a given module version - The missing-translation check only runs when the term store itself has more than one configured language — a single-language store produces no translation report at all
- Unused term set detection only looks at standard managed metadata (Taxonomy) columns'
TermSetIdproperty parsed from each field's schema XML — it can't see term sets referenced by custom code, workflows, or non-standard field types, so "unused" here means "not bound to a Taxonomy column," not "genuinely untouched" - The usage check connects to every non-personal, non-system site in the tenant to inspect its columns, making it by far the slowest part of the script; sites that error out during the scan are logged and skipped rather than halting the run, which can undercount true usage
- OneDrive personal sites and known system-managed templates (App Catalog, Search Center, MySite Host, Tenant Admin) are excluded from the usage check
Prerequisites
- PnP.PowerShell 3.x module installed
- SharePoint Administrator or Global Administrator permissions (required to enumerate every tenant site during the usage check)
- Read access to the term store (Manage Taxonomy or Term Store Administrator)
- An Azure AD app registration with the required permissions, or use interactive login
PowerShell Script
<#
.SYNOPSIS
Term store orientation and governance analysis: full inventory of
groups/sets/terms, empty term sets, terms with missing translations
across configured languages, and term sets that appear unused
anywhere in the tenant.
.DESCRIPTION
Four questions, matching the spirit of Get-SPOContentTypeAnalysis.ps1:
1. INVENTORY: every term group, term set, term count, and whether
the set is open (anyone can add terms) or closed, a
real governance signal, an open term set with a large ad hoc term
count is usually a sign nobody's taking care of it
2. EMPTY TERM SETS: sets with zero terms.
3. MISSING TRANSLATIONS: for any term set using more than one
language, terms where a label exists in one configured language
but is missing in another. Directly relevant to any multilingual
search
4. UNUSED TERM SETS: sets that exist in the store but don't appear to
be referenced by any managed metadata column anywhere in the
tenant
NOTE ON API SURFACE: Treat
this script's Open/Closed detection as the part most likely to need
a small correction int he future
.NOTES
Requires: PnP.PowerShell 3.x
Author: Cameron Griffiths
.EXAMPLE
.\Get-SPOTermStoreAnalysis.ps1 -AdminUrl "https://tenantName-admin.sharepoint.com"
#>
[CmdletBinding()]
param(
[Parameter(Mandatory = $false)]
[string]$AdminUrl = "https://tenantName-admin.sharepoint.com",
[Parameter(Mandatory = $false)]
[string]$ClientId = "",
[Parameter(Mandatory = $false)]
[string]$OutputFolder = ".\TermStoreAnalysis_$(Get-Date -Format 'yyyyMMdd_HHmmss')",
[Parameter(Mandatory = $false)]
[int]$ThrottleDelayMs = 300,
[Parameter(Mandatory = $false)]
[switch]$SkipUsageCheck
)
New-Item -ItemType Directory -Path $OutputFolder -Force | Out-Null
Write-Host "Connecting to $AdminUrl ..." -ForegroundColor Cyan
Connect-PnPOnline -Url $AdminUrl -Interactive -ClientId $ClientId
Write-Host "Retrieving term store ..." -ForegroundColor Cyan
$termStore = Get-PnPSiteCollectionTermStore
$storeLanguages = $termStore.Languages
Write-Host "Term store languages configured: $($storeLanguages -join ', ')" -ForegroundColor Cyan
$systemGroups = @(
"Search Dictionaries",
"System"
)
# Site Collection groups (named "Site Collection - <site url>") are also
# system-provisioned per site, not part of the shared taxonomy, excluded
# below via a wildcard match rather than an exact name.
$inventory = New-Object System.Collections.Generic.List[Object]
$emptySets = New-Object System.Collections.Generic.List[Object]
$missingTranslations = New-Object System.Collections.Generic.List[Object]
$allTermSets = New-Object System.Collections.Generic.List[Object]
Write-Host "Enumerating term groups and sets ..." -ForegroundColor Cyan
$groups = Get-PnPTermGroup | Where-Object {
$systemGroups -notcontains $_.Name -and $_.Name -notlike "Site Collection - *"
}
foreach ($group in $groups) {
$termSets = Get-PnPTermSet -TermGroup $group
foreach ($set in $termSets) {
$terms = Get-PnPTerm -TermSet $set -TermGroup $group -Recursive
$termCount = ($terms | Measure-Object).Count
# Open/Closed detection, see NOTE in header comment
$isOpen = $null
try { $isOpen = $set.IsOpenForTermCreation } catch { $isOpen = "UNKNOWN (property not available on this cmdlet version)" }
$inventory.Add([PSCustomObject]@{
GroupName = $group.Name
SetName = $set.Name
TermCount = $termCount
IsOpen = $isOpen
SetId = $set.Id
})
$allTermSets.Add([PSCustomObject]@{
GroupName = $group.Name
SetName = $set.Name
SetId = $set.Id
})
if ($termCount -eq 0) {
$emptySets.Add([PSCustomObject]@{
GroupName = $group.Name
SetName = $set.Name
SetId = $set.Id
})
continue
}
# --- Missing translation check, only meaningful if the store has more than one language ---
if ($storeLanguages.Count -gt 1) {
foreach ($term in $terms) {
$labelLanguages = $term.Labels | ForEach-Object { $_.LanguageTag }
$missing = $storeLanguages | Where-Object { $labelLanguages -notcontains $_ }
if ($missing) {
$missingTranslations.Add([PSCustomObject]@{
GroupName = $group.Name
SetName = $set.Name
TermName = $term.Labels[0].Name
TermId = $term.Id
MissingLanguages = ($missing -join "; ")
})
}
}
}
}
}
Write-Host "Found $($groups.Count) term groups, $($allTermSets.Count) term sets." -ForegroundColor Cyan
# --- Usage check: which term sets are actually referenced by a managed metadata column somewhere in the tenant ---
$usedSetIds = New-Object System.Collections.Generic.HashSet[string]
if (-not $SkipUsageCheck) {
Write-Host "Scanning tenant sites for managed metadata column usage (this is the slow part) ..." -ForegroundColor Cyan
$systemTemplates = @("APPCATALOG#0", "SRCHCEN#0", "SPSMSITEHOST#0", "SPSTOC#0", "TENANTADMIN#0")
$sites = Get-PnPTenantSite -Detailed | Where-Object {
$_.Template -notlike "SPSPERS*" -and $_.Url -notlike "*-my.sharepoint.com*"
}
$counter = 0
foreach ($site in $sites) {
$counter++
Write-Progress -Activity "Scanning for term set usage" -Status $site.Url -PercentComplete (($counter / $sites.Count) * 100)
if ($systemTemplates -contains $site.Template) { continue }
try {
Connect-PnPOnline -Url $site.Url -Interactive -ClientId $ClientId -WarningAction SilentlyContinue
$mmFields = Get-PnPField | Where-Object { $_.TypeAsString -like "TaxonomyField*" }
foreach ($field in $mmFields) {
try {
$ssXml = [xml]$field.SchemaXml
$termSetIdNode = $ssXml.Field.Customization.ArrayOfProperty.Property | Where-Object { $_.Name -eq "TermSetId" }
if ($termSetIdNode) {
[void]$usedSetIds.Add($termSetIdNode.Value.'#text')
}
}
catch { continue }
}
}
catch {
Write-Host "Could not scan $($site.Url): $($_.Exception.Message)" -ForegroundColor Yellow
}
Start-Sleep -Milliseconds $ThrottleDelayMs
}
Write-Progress -Activity "Scanning for term set usage" -Completed
}
$unusedSets = $allTermSets | Where-Object { -not $usedSetIds.Contains($_.SetId.ToString()) }
# --- Output ---
$inventory | Sort-Object GroupName, SetName | Export-Csv -Path "$OutputFolder\1-TermStoreInventory.csv" -NoTypeInformation -Encoding UTF8
$emptySets | Sort-Object GroupName, SetName | Export-Csv -Path "$OutputFolder\2-EmptyTermSets.csv" -NoTypeInformation -Encoding UTF8
$missingTranslations | Sort-Object GroupName, SetName, TermName | Export-Csv -Path "$OutputFolder\3-MissingTranslations.csv" -NoTypeInformation -Encoding UTF8
if (-not $SkipUsageCheck) {
$unusedSets | Sort-Object GroupName, SetName | Export-Csv -Path "$OutputFolder\4-UnusedTermSets.csv" -NoTypeInformation -Encoding UTF8
}
Write-Host "`nAnalysis complete. Reports written to $OutputFolder" -ForegroundColor Green
Write-Host "`n--- Summary ---" -ForegroundColor Yellow
Write-Host "Term groups: $($groups.Count)"
Write-Host "Term sets: $($allTermSets.Count)"
Write-Host "Empty term sets: $($emptySets.Count)"
Write-Host "Terms with missing translations: $($missingTranslations.Count)"
if (-not $SkipUsageCheck) {
Write-Host "Term sets with no detected usage anywhere in the tenant: $($unusedSets.Count)"
}
else {
Write-Host "Usage check skipped (-SkipUsageCheck was set)."
}
Usage Notes
-AdminUrldefaults to a placeholder — pass your SharePoint admin centre URL, e.g.https://contoso-admin.sharepoint.com-ClientIdis your Azure AD app registration client ID, or leave blank to use interactive login without a registered app-OutputFolderdefaults to a timestamped folder in the current directory-ThrottleDelayMscontrols the pause between site connections during the usage check (default 300ms) — increase it on larger tenants to avoid throttling-SkipUsageCheckskips the tenant-wide site scan entirely, useful for a quick inventory/translation pass without the slow part1-TermStoreInventory.csvlists every group/set with term count and open/closed status;2-EmptyTermSets.csvlists sets with zero terms3-MissingTranslations.csvlists individual terms missing a label in one of the store's configured languages, only populated if the store has more than one language configured4-UnusedTermSets.csvlists sets with no detected Taxonomy column usage anywhere in the tenant — only written if-SkipUsageCheckwas not set- Sites the usage check can't connect to are logged to the console and skipped rather than halting the run
Related
- Create Multilingual Term Store — the natural inverse of this script's missing-translations check: use it to provision term groups and sets with fully translated labels from the start
- Delete Term Group — once this script's empty or unused term sets reports turn up abandoned groups, use this to remove them