SCRIPT LIBRARY · POWERSHELL
Exporting Active Directory OUs to CSV (With Paths You Can Actually Read)
Dump every OU in the domain to a spreadsheet, with a readable path, depth, GPO link count and, if you want, how many objects live in each one.
- What it does
- Exports every OU under a search base to CSV with its readable path, depth, parent, GPO link count and deletion protection, plus optional counts of the users, computers and groups inside each one.
- Requires
- Windows PowerShell 5.1 or PowerShell 7 on Windows
- ActiveDirectory module (RSAT)
- Permissions
- Any domain user can read this by default. No admin rights needed.
- Runs on
- Windows 10/11 with RSAT, Windows Server 2016+
- Tested
- Parse-checked and dry-run with mocked AD cmdlets in PowerShell 7.4
Part 3 of the thread Keeping Active Directory tidy
Sooner or later somebody asks for "a list of all our OUs." Maybe it's an auditor, maybe it's you, trying to figure out why there are three different OUs called Laptops and which one the laptops are actually in.
A plain Get-ADOrganizationalUnit | Export-Csv technically answers the question, but distinguished names read backwards and are miserable to sort. This script gives each OU a path you can read top-down (Corp / Devices / Laptops), its depth, its parent, how many GPOs are linked to it, and whether it's protected from accidental deletion. Add -IncludeObjectCount and you'll also see how many users, computers and groups sit in each one, which is the quickest way I know to find the empty OUs nobody's used in years.
The old version of this script tried to split each DN into OU1, OU2, OU3 columns, but a bug overwrote the row on every pass and it needed a search base filled in by hand. This one works out the domain root for you, and it sorts by path so the CSV reads like a tree.
<#
.SYNOPSIS
Exports Active Directory OUs to CSV, with their path, depth, GPO links and object counts.
.DESCRIPTION
Reads every OU under a search base and turns each one into a flat, spreadsheet-friendly
row: name, readable path, depth, parent, whether it's protected from accidental deletion,
how many GPOs are linked to it, and (optionally) how many users, computers and groups sit
directly inside it. Writes a CSV and can return the objects too.
.PARAMETER SearchBase
Where to start. Defaults to the root of the domain.
.PARAMETER CsvPath
Output file. Defaults to ad-ous-<date>.csv in the current folder.
.PARAMETER IncludeObjectCount
Also count users, computers and groups directly in each OU. Slower on big directories.
.PARAMETER Server
Domain or domain controller to query.
.PARAMETER PassThru
Return the rows as objects as well as writing the CSV.
.EXAMPLE
.\Export-ADOrganizationalUnit.ps1
.EXAMPLE
.\Export-ADOrganizationalUnit.ps1 -SearchBase 'OU=Branches,DC=contoso,DC=com' -IncludeObjectCount -PassThru | Where-Object Computers -eq 0
#>
[CmdletBinding()]
param(
[string]$SearchBase,
[string]$CsvPath = (Join-Path (Get-Location) ('ad-ous-{0:yyyy-MM-dd}.csv' -f (Get-Date))),
[switch]$IncludeObjectCount,
[string]$Server,
[switch]$PassThru
)
$ErrorActionPreference = 'Stop'
Import-Module ActiveDirectory
$ad = @{}
if ($Server) { $ad.Server = $Server }
if (-not $SearchBase) { $SearchBase = (Get-ADDomain @ad).DistinguishedName }
$props = 'CanonicalName', 'Description', 'gPLink', 'ProtectedFromAccidentalDeletion', 'whenCreated', 'ManagedBy'
$ous = Get-ADOrganizationalUnit -Filter * -SearchBase $SearchBase -SearchScope Subtree -Properties $props @ad
Write-Verbose "Found $(@($ous).Count) OUs under $SearchBase"
$rows = foreach ($ou in $ous) {
# CanonicalName is contoso.com/Corp/Devices/Laptops; drop the domain part for a readable path.
# A slash inside an OU name comes through escaped (\/), so don't split on those.
$parts = @($ou.CanonicalName -split '(?<!\\)/' | ForEach-Object { $_ -replace '\\/', '/' })
$row = [ordered]@{
Name = $ou.Name
Path = ($parts | Select-Object -Skip 1) -join ' / '
Depth = $parts.Count - 1
Parent = ($ou.DistinguishedName -split '(?<!\\),', 2)[1]
Description = $ou.Description
LinkedGPOs = ([regex]::Matches([string]$ou.gPLink, '\[LDAP://')).Count
ProtectedFromDeletion = $ou.ProtectedFromAccidentalDeletion
ManagedBy = $ou.ManagedBy
Created = $ou.whenCreated
DistinguishedName = $ou.DistinguishedName
}
if ($IncludeObjectCount) {
$scope = @{ SearchBase = $ou.DistinguishedName; SearchScope = 'OneLevel' }
try {
$row.Users = @(Get-ADUser -Filter * @scope @ad).Count
$row.Computers = @(Get-ADComputer -Filter * @scope @ad).Count
$row.Groups = @(Get-ADGroup -Filter * @scope @ad).Count
}
catch {
Write-Warning "Couldn't count objects in $($ou.DistinguishedName): $($_.Exception.Message)"
$row.Users = $row.Computers = $row.Groups = $null
}
}
[pscustomobject]$row
}
$rows = @($rows | Sort-Object Path)
$rows | Export-Csv -Path $CsvPath -NoTypeInformation -Encoding UTF8
Write-Host "Exported $($rows.Count) OUs to $CsvPath"
if ($PassThru) { $rows }
Parameters
| Parameter | Type | Default | What it's for |
|---|---|---|---|
-SearchBase | string | domain root | Only export OUs under this DN. Leave it off for the whole domain. |
-CsvPath | string | .\ad-ous-<date>.csv | Where to save the CSV. |
-IncludeObjectCount | switch | — | Count the users, computers and groups directly inside each OU. It's three extra queries per OU, so expect it to take a while on a big directory. |
-Server | string | — | Domain name or domain controller to query. |
-PassThru | switch | — | Return the rows as objects too, so you can filter them in the same command. |
Run it
Every OU in the domain, saved to a dated CSV in the current folder.
.\Export-ADOrganizationalUnit.ps1Just one branch of the tree, with object counts.
.\Export-ADOrganizationalUnit.ps1 -SearchBase 'OU=Corp,DC=contoso,DC=com' -IncludeObjectCountFind empty OUs. No users, computers or groups directly inside.
.\Export-ADOrganizationalUnit.ps1 -IncludeObjectCount -PassThru | Where-Object { $_.Users + $_.Computers + $_.Groups -eq 0 }OUs that could be deleted by one careless right-click.
.\Export-ADOrganizationalUnit.ps1 -PassThru | Where-Object ProtectedFromDeletion -eq $falseWhat you'll see
Exported 214 OUs to C:\Reports\ad-ous-2026-09-29.csv
Name Path Depth LinkedGPOs ProtectedFromDeletion Users Computers Groups
---- ---- ----- ---------- --------------------- ----- --------- ------
Corp Corp 1 2 True 0 0 0
Devices Corp / Devices 2 1 True 0 3 0
Laptops Corp / Devices / Laptops 3 2 True 0 412 0
Old-Kiosks Corp / Devices / Old-Kiosks 3 0 False 0 0 0
Users Corp / Users 2 1 True 688 0 41
How it works
- Find the starting point. If you don't give it a search base, it asks AD for the domain's DN and starts at the root.
- Grab every OU with a few extra properties.
CanonicalNamefor the readable path,gPLinkfor the GPO links, andProtectedFromAccidentalDeletion, which is the checkbox you see in Active Directory Users and Computers. - Flatten each one into a row. The path comes from the canonical name with the domain part dropped. Depth is how many levels down it sits, and the GPO count is just how many
[LDAP://...]entries are ingPLink. - Optionally count what's inside. One-level queries for users, computers and groups. If one OU can't be read, you get a warning and blank counts for that row instead of a dead script.
- Sort, save, and hand back. Sorted by path so parents come before children, written to CSV, and returned as objects if you asked for
-PassThru.
Take it further
- Document your GPO links too. Add a column with the linked GPO names by passing each OU to
Get-GPInheritancefrom the GroupPolicy module. - Track changes over time. Save a copy every month and compare two with
Compare-Object -Property DistinguishedNameto see which OUs appeared or disappeared. - Pair it with the computer export. The computer object export tells you what's in the OUs; this tells you what the OUs are.
Things that'll trip you up
- The built-in Users and Computers aren't OUs. CN=Users and CN=Computers are containers, so they don't show up here. If objects are piling up in them, that's worth a look, but it's a different query.
- Counts are one level deep. -IncludeObjectCount counts what's directly inside each OU, not what's in the OUs below it. A parent OU showing zero is normal if everything lives in its children.
- LinkedGPOs counts links, not enabled links. A link that's been disabled still counts. If you need to know which ones are live, Get-GPInheritance on that OU will tell you.
- Slashes in OU names are handled. AD escapes a slash inside a name in the canonical name, so "Sales/Marketing" doesn't get split into two levels in the Path column.