SCRIPT LIBRARY · POWERSHELL
See What's in a Folder Tree, with File Counts and Sizes
Walk a folder once and get every subfolder's file count and total size in tree order, ready to sort, filter or save as a CSV.
- What it does
- Walks a folder tree once and returns one object per folder, in tree order, with the files sitting directly in it, the files in it and everything below, the total size and the newest file date. Optionally lists the files too and saves the whole thing as CSV.
- Requires
- Windows PowerShell 5.1 or PowerShell 7+
- No modules
- Permissions
- Read access to the folders you're reporting on. No admin rights, though an elevated prompt will see into more system folders.
- Runs on
- Windows 10/11, Windows Server 2016+ (and PowerShell 7 on macOS or Linux)
- Tested
- Parse-checked and run in PowerShell 7.4 against a test folder tree, including hidden files, an empty folder, -Depth, -IncludeFiles, pipeline input and CSV export
Part 8 of the thread File wrangling
Every so often a drive fills up and the question is always the same: where did it all go? Explorer will tell you, one folder at a time, if you're willing to right-click Properties on each one and wait for the count to stop spinning.
This started as a little interactive script on my own machine. It asked for a path, then printed every folder and every file in a long wall of colored text. Handy the first time. Useless the second, because you couldn't sort it, filter it or save it anywhere.
The rewrite walks the tree once, rolls every file up into its own folder and every folder above it, and hands back one object per folder in tree order. Read it as a tree, sort it by size to find the hogs, or drop it into a CSV. If you're about to move or archive a big folder, it makes a good "before" picture.
<#
.SYNOPSIS
Shows a folder tree with file counts and sizes, and optionally saves it as CSV.
.DESCRIPTION
Walks a folder once, adds up every file into its own folder and all of the folders above it,
then returns one object per folder in tree order: how many files sit directly in it, how many
are in it and everything below, and the total size. Use -Depth to keep the listing short
without changing the totals, -IncludeFiles to list the files as well, and -CsvPath to save
the rows for Excel. Folders you can't read are counted as warnings, not errors.
.PARAMETER Path
The folder to report on. Default: the current folder.
.PARAMETER Depth
How many levels below Path to list. Totals always include everything underneath. Default: all levels.
.PARAMETER IncludeFiles
List each file under its folder too.
.PARAMETER IncludeHidden
Count hidden and system files and folders.
.PARAMETER CsvPath
Also save the rows to this CSV file.
.EXAMPLE
.\Get-DirectoryReport.ps1 -Path D:\Shared -Depth 2
.EXAMPLE
.\Get-DirectoryReport.ps1 -Path D:\Shared -CsvPath .\shared-report.csv | Sort-Object SizeMB -Descending | Select-Object -First 10
#>
[CmdletBinding()]
param(
[Parameter(Position = 0, ValueFromPipeline, ValueFromPipelineByPropertyName)]
[Alias('FullName')]
[ValidateNotNullOrEmpty()]
[string]$Path = '.',
[ValidateRange(0, 1000)][int]$Depth = 1000,
[switch]$IncludeFiles,
[switch]$IncludeHidden,
[string]$CsvPath
)
process {
try {
$root = (Get-Item -LiteralPath $Path -Force -ErrorAction Stop)
}
catch {
Write-Error "Can't open '$Path': $($_.Exception.Message)"
return
}
if (-not $root.PSIsContainer) { Write-Error "'$Path' is a file, not a folder."; return }
# Trim a trailing slash, except on a drive root like C:\ where it matters.
$rootPath = $root.FullName
if ($rootPath.Length -gt [IO.Path]::GetPathRoot($rootPath).Length) { $rootPath = $rootPath.TrimEnd([IO.Path]::DirectorySeparatorChar, [IO.Path]::AltDirectorySeparatorChar) }
$sep = [IO.Path]::DirectorySeparatorChar
# Show the useful columns by default; the rest are still there for Select-Object and the CSV.
$display = [System.Management.Automation.PSPropertySet]::new('DefaultDisplayPropertySet', [string[]]@('Tree', 'Files', 'TotalFiles', 'SizeMB'))
$standardMembers = [System.Management.Automation.PSMemberInfo[]]@($display)
# One pass for folders, one for files. Unreadable folders land in $denied instead of stopping the run.
$denied = @()
$folders = @(Get-ChildItem -LiteralPath $rootPath -Directory -Recurse -Force:$IncludeHidden -ErrorAction SilentlyContinue -ErrorVariable +denied)
$files = @(Get-ChildItem -LiteralPath $rootPath -File -Recurse -Force:$IncludeHidden -ErrorAction SilentlyContinue -ErrorVariable +denied)
if ($denied) { Write-Warning "$($denied.Count) item(s) couldn't be read (usually access denied). Totals leave them out. Run with -Verbose to see which." }
foreach ($d in $denied) { Write-Verbose "Skipped: $($d.TargetObject)" }
# A stats entry per folder, plus a child list so the tree can be walked in order.
$stats = @{}
$children = @{}
$newStat = { [pscustomobject]@{ Files = 0; TotalFiles = 0; Bytes = [long]0; Folders = 0; LastWrite = $null } }
$stats[$rootPath] = & $newStat
$children[$rootPath] = [System.Collections.Generic.List[string]]::new()
foreach ($f in $folders) {
$stats[$f.FullName] = & $newStat
$children[$f.FullName] = [System.Collections.Generic.List[string]]::new()
}
foreach ($f in $folders) {
$parent = [IO.Path]::GetDirectoryName($f.FullName)
if ($children.ContainsKey($parent)) { $children[$parent].Add($f.FullName); $stats[$parent].Folders++ }
}
$filesByFolder = @{}
foreach ($file in $files) {
$dir = $file.DirectoryName
if (-not $stats.ContainsKey($dir)) { continue } # its folder was hidden or unreadable
$stats[$dir].Files++
if (-not $filesByFolder.ContainsKey($dir)) { $filesByFolder[$dir] = [System.Collections.Generic.List[object]]::new() }
$filesByFolder[$dir].Add($file)
# Roll the file up into this folder and every folder above it, stopping at the root.
$walk = $dir
while ($walk) {
$s = $stats[$walk]
$s.TotalFiles++
$s.Bytes += $file.Length
if (-not $s.LastWrite -or $file.LastWriteTime -gt $s.LastWrite) { $s.LastWrite = $file.LastWriteTime }
if ($walk -eq $rootPath) { break }
$walk = [IO.Path]::GetDirectoryName($walk)
if (-not $stats.ContainsKey($walk)) { break }
}
}
$rows = [System.Collections.Generic.List[object]]::new()
$stack = [System.Collections.Generic.Stack[object]]::new()
$stack.Push(@($rootPath, 0))
while ($stack.Count) {
$folder, $level = $stack.Pop()
$s = $stats[$folder]
$relative = if ($folder -eq $rootPath) { '.' } else { $folder.Substring($rootPath.Length).TrimStart($sep) }
$name = if ($folder -eq $rootPath) { $root.Name } else { Split-Path -Path $folder -Leaf }
$rows.Add([pscustomobject]@{
Tree = (' ' * $level) + $name
Type = 'Folder'
Level = $level
Files = $s.Files
Folders = $s.Folders
TotalFiles = $s.TotalFiles
SizeMB = [math]::Round($s.Bytes / 1MB, 2)
LastWriteTime = $s.LastWrite
RelativePath = $relative
FullName = $folder
})
if ($IncludeFiles -and $filesByFolder.ContainsKey($folder)) {
foreach ($file in ($filesByFolder[$folder] | Sort-Object Name)) {
$rows.Add([pscustomobject]@{
Tree = (' ' * ($level + 1)) + $file.Name
Type = 'File'
Level = $level + 1
Files = $null
Folders = $null
TotalFiles = $null
SizeMB = [math]::Round($file.Length / 1MB, 2)
LastWriteTime = $file.LastWriteTime
RelativePath = $file.FullName.Substring($rootPath.Length).TrimStart($sep)
FullName = $file.FullName
})
}
}
if ($level -lt $Depth) {
# Push in reverse so the stack pops them alphabetically.
foreach ($child in ($children[$folder] | Sort-Object -Descending)) { $stack.Push(@($child, ($level + 1))) }
}
}
if ($CsvPath) {
try {
$rows | Select-Object Type, Level, RelativePath, Files, Folders, TotalFiles, SizeMB, LastWriteTime, FullName |
Export-Csv -LiteralPath $CsvPath -NoTypeInformation -Encoding utf8 -ErrorAction Stop
Write-Verbose "Saved $($rows.Count) row(s) to $CsvPath"
}
catch { Write-Warning "Couldn't write $CsvPath : $($_.Exception.Message)" }
}
foreach ($row in $rows) {
$row | Add-Member -MemberType MemberSet -Name PSStandardMembers -Value $standardMembers -PassThru
}
}
Parameters
| Parameter | Type | Default | What it's for |
|---|---|---|---|
-Path | string | . | The folder to report on. Takes pipeline input, so Get-Item and Get-ChildItem -Directory can feed it. |
-Depth | int | all levels | How many levels below Path to list. The totals still count everything underneath, so -Depth 1 gives you the size of each top-level folder. |
-IncludeFiles | switch | — | List each file under its folder as well. Big trees get long fast, so pair it with -Depth or -CsvPath. |
-IncludeHidden | switch | — | Count hidden and system files and folders too. |
-CsvPath | string | — | Also save the rows to this CSV file. The objects still come down the pipeline. |
Run it
The whole tree under a folder.
.\Get-DirectoryReport.ps1 -Path D:\SharedJust the top two levels. The totals still include everything below them.
.\Get-DirectoryReport.ps1 -Path D:\Shared -Depth 2The ten biggest folders anywhere in the tree.
.\Get-DirectoryReport.ps1 -Path D:\Shared | Sort-Object SizeMB -Descending | Select-Object -First 10 RelativePath, TotalFiles, SizeMBFolders with nothing in them at all, at any depth.
.\Get-DirectoryReport.ps1 -Path D:\Shared | Where-Object TotalFiles -eq 0 | Select-Object RelativePathA full inventory with every file listed, saved for Excel.
.\Get-DirectoryReport.ps1 -Path D:\Shared -IncludeFiles -IncludeHidden -CsvPath .\shared-inventory.csv | Out-NullWhat you'll see
WARNING: 3 item(s) couldn't be read (usually access denied). Totals leave them out. Run with -Verbose to see which.
Tree Files TotalFiles SizeMB
---- ----- ---------- ------
Shared 4 18422 48213.77
Archive 0 9310 30511.02
2023 412 412 2211.40
2024 8898 8898 28299.62
Finance 36 214 180.35
Invoices 178 178 121.09
Projects 2 8894 17511.84
Contoso Rebrand 6120 6120 15022.10
Website 2772 2772 2489.74
Scans 0 0 0.00
How it works
- Two listings, one pass each.
Get-ChildItem -Recurseruns once for folders and once for files. Anything it can't read goes into an error variable instead of stopping the run, and turns into one warning at the end. - Roll every file up. Each file's size and count is added to its own folder and then to every folder above it, up to the root. That's how
TotalFilesandSizeMBcover a whole subtree, whileFilesis only what sits directly in that folder.LastWriteTimeis the newest file anywhere underneath, which is a quick way to spot folders nobody has touched in years. - Walk the tree in order. A stack does the walk, with each folder's children pushed in reverse so they come back out alphabetically. The
Treecolumn indents two spaces per level.-Depthstops the walk, not the counting. - Hand back objects. The default view shows four columns so it fits on screen.
RelativePath,Folders,Level,LastWriteTimeandFullNameare all there forSelect-Object,Where-Objectand the CSV.
Take it further
- Compare before and after a move. Save a CSV before you move a list of folders to a new drive, run it again against the new location, and compare
TotalFilesperRelativePath. Any row that doesn't match is worth a look. - Find archive candidates. Filter on
LastWriteTimeolder than a year and you've got a shortlist for zipping up folders nobody has touched. - Turn it into a picture. Open the CSV in Excel, filter to
TypeFolder, and insert a treemap onRelativePathandSizeMB. The big blocks jump right out.
Things that'll trip you up
- Size means file size, not space on disk. The totals add up each file's length. Compressed folders, sparse files and cloud-only OneDrive placeholders all report their full size even when they take up far less (or nothing) on the drive, so the numbers won't always match what the disk says is free.
- Hidden and system files are left out by default. That matches what Explorer shows most people. Add -IncludeHidden when you're chasing space, because hidden folders like caches and app data are often where it went.
- Access denied doesn't stop the run. Folders you can't read are collected and reported as a single warning, and their contents aren't in the totals. Use -Verbose to list them, and run from an elevated prompt if you need the system folders counted too.
- Huge trees take memory. The script holds every file object while it adds things up. A few hundred thousand files is fine. For tens of millions, run it one top-level folder at a time.