Get-SPOContentTypeAnalysis
This PnP PowerShell script runs a tenant-wide content type audit that answers four separate governance questions in one pass: what's published from the Content Type Hub, where each of those global content types is actually used (not just theoretically available), what content types sites have created locally outside the hub, and which list-level content types have drifted from their parent's field structure, a practical form of broken inheritance. Results are exported as four separate CSVs so each question can be worked independently.
Purpose
- Connects to the Content Type Hub first to build the reference list of globally published content types, identified by exact content type Id match
- Walks every site in the tenant and records which of those global content types are actually in use, and on which lists, rather than just present in the site's content type gallery
- Inventories local content types, ones created directly on individual sites outside the hub, since this is usually the more operationally important number: it's uncontrolled by design and prone to sites independently inventing near-identical content types under different names
- Compares each list-level content type's
FieldLinksagainst its parent site content type and flags any that have diverged, meaning someone customized the list's copy directly rather than going through the site content type - Excludes SharePoint's built-in content type groups (List Content Types, Document Content Types, Folder Content Types, Special Content Types, Business Intelligence, and others) from all four analyses, since every site has them by default and they aren't governance-relevant
- Exports four CSVs plus a console summary, including a callout for local content type names that appear on more than one site, the clearest signal of likely duplication
Scope
- A content type counts as "global" only on an exact Id match against what's published on the Content Type Hub — a locally created content type that merely shares a name with a hub content type is still reported as local
- The broken inheritance check compares field names only (via
FieldLinks), not other field-link properties like required, hidden, or display order — a list-level content type can pass this check and still have settings drift that this script won't catch - This is drift, not a true SharePoint error state — a list content type with different fields than its parent still functions normally, it's just no longer a faithful copy of what the site content type defines
- Global content type usage is measured by presence on a list's
ContentTypescollection, not by whether any items have actually been created with it — a content type added to a list but never used by any item still counts as "in use" here - OneDrive personal sites and known system-managed templates (App Catalog, Search Center, MySite Host, Tenant Admin) are excluded, along with the Content Type Hub site itself
Prerequisites
- PnP.PowerShell 3.x module installed
- SharePoint Administrator or Global Administrator permissions
- Read access to the Content Type Hub site
- An Azure AD app registration with the required permissions, or use interactive login
PowerShell Script
<#
.SYNOPSIS
Tenant-wide content type analysis: inventories global (Content Type
Hub) content types and where they're actually used, inventories local
(site-created, non-syndicated) content types for sprawl analysis, and
flags content types whose field structure has drifted from their
parent, a form of broken inheritance.
.DESCRIPTION
Four distinct answers different governance problems
1. GLOBAL CONTENT TYPES: what's published from the Content Type Hub,
counted and named.
2. GLOBAL CONTENT TYPE USAGE: which sites and lists actually have each
global content type in use, not just theoretically available.
3. LOCAL CONTENT TYPES: content types created directly on individual
sites, outside the hub, no central visibility, no approval step.
This is usually the more operationally important number, since
it's uncontrolled by design and prone to duplication (multiple
sites independently inventing near-identical content types under
different names).
4. BROKEN INHERITANCE: a list-level content type whose field
structure (FieldLinks) has diverged from its parent site content
type, indicating someone customized it directly on the list rather
than through the site content type itself. This is drift, not a
true SharePoint error state, but it's the practical equivalent of
"broken inheritance" for content types, the list's copy no longer
faithfully reflects its parent.
A global content type is identified by exact Id match against what's
published on the Content Type Hub site. Built-in SharePoint groups
(List Content Types, Document Content Types, Folder Content Types,
Special Content Types, Business Intelligence, Digital Asset Content
Types, Document Set Content Types, Page Layout Content Types,
Publishing Content Types, _Hidden) are excluded entirely from all
four analyses, since they're not governance-relevant, every site has
them by default.
.NOTES
Requires: PnP.PowerShell 3.x
Author: Cameron Griffiths
#>
[CmdletBinding()]
param(
[Parameter(Mandatory = $false)]
[string]$AdminUrl = "https://tenantName-admin.sharepoint.com",
[Parameter(Mandatory = $false)]
[string]$HubSiteUrl = "https://tenantName.sharepoint.com/sites/contenttypehub",
[Parameter(Mandatory = $false)]
[string]$ClientId = "",
[Parameter(Mandatory = $false)]
[string]$OutputFolder = ".\ContentTypeAnalysis_$(Get-Date -Format 'yyyyMMdd_HHmmss')",
[Parameter(Mandatory = $false)]
[int]$ThrottleDelayMs = 300
)
# --- Built-in groups to exclude from all analysis ---
$builtInGroups = @(
"List Content Types",
"Document Content Types",
"Folder Content Types",
"Special Content Types",
"_Hidden",
"Business Intelligence",
"Digital Asset Content Types",
"Document Set Content Types",
"Page Layout Content Types",
"Publishing Content Types",
"Community Content Types"
)
$systemTemplates = @(
"APPCATALOG#0", "SRCHCEN#0", "SPSMSITEHOST#0", "SPSTOC#0", "TENANTADMIN#0"
)
New-Item -ItemType Directory -Path $OutputFolder -Force | Out-Null
# --- Connect to the hub site first, to build the global content type reference list ---
Write-Host "Connecting to Content Type Hub: $HubSiteUrl ..." -ForegroundColor Cyan
Connect-PnPOnline -Url $HubSiteUrl -Interactive -ClientId $ClientId
$hubContentTypes = Get-PnPContentType | Where-Object { $builtInGroups -notcontains $_.Group }
Write-Host "Found $($hubContentTypes.Count) global (hub-published) content types." -ForegroundColor Cyan
$globalIds = $hubContentTypes.Id.StringValue
# --- Connect to the admin site and get the full tenant site list ---
Write-Host "Connecting to $AdminUrl ..." -ForegroundColor Cyan
Connect-PnPOnline -Url $AdminUrl -Interactive -ClientId $ClientId
$sites = Get-PnPTenantSite -Detailed | Where-Object {
$_.Template -notlike "SPSPERS*" -and $_.Url -notlike "*-my.sharepoint.com*"
}
Write-Host "Found $($sites.Count) sites to analyse." -ForegroundColor Cyan
$globalUsage = New-Object System.Collections.Generic.List[Object]
$localContentTypes = New-Object System.Collections.Generic.List[Object]
$brokenInheritance = New-Object System.Collections.Generic.List[Object]
$counter = 0
foreach ($site in $sites) {
$counter++
Write-Progress -Activity "Analysing content types" -Status $site.Url -PercentComplete (($counter / $sites.Count) * 100)
if ($systemTemplates -contains $site.Template) { continue }
if ($site.Url -eq $HubSiteUrl) { continue }
try {
Connect-PnPOnline -Url $site.Url -Interactive -ClientId $ClientId -WarningAction SilentlyContinue
$siteContentTypes = Get-PnPContentType | Where-Object { $builtInGroups -notcontains $_.Group }
foreach ($ct in $siteContentTypes) {
if ($globalIds -contains $ct.Id.StringValue) {
# --- Global content type: check where it's actually used ---
$lists = Get-PnPList -Includes ContentTypes
foreach ($list in $lists) {
$match = $list.ContentTypes | Where-Object { $_.Id.StringValue -eq $ct.Id.StringValue }
if ($match) {
$globalUsage.Add([PSCustomObject]@{
ContentTypeName = $ct.Name
ContentTypeId = $ct.Id.StringValue
SiteUrl = $site.Url
ListTitle = $list.Title
ItemCount = $list.ItemCount
})
}
}
}
else {
# --- Local content type ---
$localContentTypes.Add([PSCustomObject]@{
SiteUrl = $site.Url
ContentTypeName = $ct.Name
Group = $ct.Group
ContentTypeId = $ct.Id.StringValue
Description = $ct.Description
})
}
}
# --- Broken inheritance check: list-level content type field drift ---
$lists = Get-PnPList -Includes ContentTypes
foreach ($list in $lists) {
foreach ($listCt in $list.ContentTypes) {
if ($builtInGroups -contains $listCt.Group) { continue }
try {
$parentCt = Get-PnPContentType -Identity $listCt.Id.StringValue -ErrorAction SilentlyContinue
if (-not $parentCt) { continue }
$listFieldLinks = Get-PnPProperty -ClientObject $listCt -Property FieldLinks
$parentFieldLinks = Get-PnPProperty -ClientObject $parentCt -Property FieldLinks
$listFieldNames = $listFieldLinks | ForEach-Object { $_.Name } | Sort-Object
$parentFieldNames = $parentFieldLinks | ForEach-Object { $_.Name } | Sort-Object
$missing = Compare-Object -ReferenceObject $parentFieldNames -DifferenceObject $listFieldNames |
Where-Object { $_.SideIndicator -eq "<=" } | Select-Object -ExpandProperty InputObject
$extra = Compare-Object -ReferenceObject $parentFieldNames -DifferenceObject $listFieldNames |
Where-Object { $_.SideIndicator -eq "=>" } | Select-Object -ExpandProperty InputObject
if ($missing -or $extra) {
$brokenInheritance.Add([PSCustomObject]@{
SiteUrl = $site.Url
ListTitle = $list.Title
ContentTypeName = $listCt.Name
MissingFieldsVsParent = ($missing -join "; ")
ExtraFieldsVsParent = ($extra -join "; ")
})
}
}
catch {
# Parent content type not resolvable, or FieldLinks not accessible, skip silently
continue
}
}
}
}
catch {
Write-Host "Could not analyse $($site.Url): $($_.Exception.Message)" -ForegroundColor Yellow
}
Start-Sleep -Milliseconds $ThrottleDelayMs
}
Write-Progress -Activity "Analysing content types" -Completed
# --- Output ---
$hubContentTypes | Select-Object Name, Group, @{N = "Id"; E = { $_.Id.StringValue } }, Description |
Export-Csv -Path "$OutputFolder\1-GlobalContentTypes.csv" -NoTypeInformation -Encoding UTF8
$globalUsage | Sort-Object ContentTypeName, SiteUrl |
Export-Csv -Path "$OutputFolder\2-GlobalContentTypeUsage.csv" -NoTypeInformation -Encoding UTF8
$localContentTypes | Sort-Object ContentTypeName, SiteUrl |
Export-Csv -Path "$OutputFolder\3-LocalContentTypes.csv" -NoTypeInformation -Encoding UTF8
$brokenInheritance | Sort-Object SiteUrl, ListTitle |
Export-Csv -Path "$OutputFolder\4-BrokenInheritance.csv" -NoTypeInformation -Encoding UTF8
Write-Host "`nAnalysis complete. Reports written to $OutputFolder" -ForegroundColor Green
Write-Host "`n--- Summary ---" -ForegroundColor Yellow
Write-Host "Global content types (from hub): $($hubContentTypes.Count)"
Write-Host "Global content type usage records: $($globalUsage.Count) (site+list combinations)"
Write-Host "Local content types found: $($localContentTypes.Count)"
$localGrouped = $localContentTypes | Group-Object ContentTypeName | Sort-Object Count -Descending
$likelyDuplicates = $localGrouped | Where-Object { $_.Count -gt 1 }
Write-Host "Local content type names appearing on multiple sites (possible duplicates): $($likelyDuplicates.Count)"
if ($likelyDuplicates.Count -gt 0) {
Write-Host "`nTop possible duplicates:" -ForegroundColor Yellow
$likelyDuplicates | Select-Object -First 10 Name, Count | Format-Table -AutoSize
}
Write-Host "Content types with field drift from their parent (broken inheritance): $($brokenInheritance.Count)"
Usage Notes
-AdminUrldefaults to a placeholder — pass your SharePoint admin centre URL, e.g.https://contoso-admin.sharepoint.com-HubSiteUrldefaults to a placeholder — pass your tenant's actual Content Type Hub URL; the script connects here first to build the global reference list before touching any other site-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, holding all four CSVs together-ThrottleDelayMscontrols the pause between site connections (default 300ms) — increase it on larger tenants to avoid throttling1-GlobalContentTypes.csvlists what's published on the hub;2-GlobalContentTypeUsage.csvlists every site+list combination where one of those is actually in use3-LocalContentTypes.csvlists every non-hub content type found on any site — the console summary also surfaces local content type names appearing on more than one site as likely duplicates worth consolidating4-BrokenInheritance.csvlists list-level content types whose field names no longer match their parent site content type, with the specific fields missing or added on each side- Sites the script can't analyse — permission issues, timeouts — are logged to the console and skipped rather than halting the run
- The
$builtInGroupsarray can be extended if your tenant has other non-governance-relevant groups you want excluded from all four reports
Related
- Create Content Types — once local sprawl or duplication turns up in the local content types report, use this to recreate the winning definition as a proper standardised content type
- Update Content Type Name — for near-duplicate local content types this analysis surfaces under different names, use this to rename toward a single consistent naming convention