Collaborate, Innovate, Automate

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

Scope

Prerequisites

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

Related