Wes Ellis./ a personal notebook
Technology. Stories. Side projects.
A few things worth writing down.
← Back to Script Library

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.

AT A GLANCEGet-DirectoryReport.ps1
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.

Get-DirectoryReport.ps1Download
<#
.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

ParameterTypeDefaultWhat it's for
-Pathstring.The folder to report on. Takes pipeline input, so Get-Item and Get-ChildItem -Directory can feed it.
-Depthintall levelsHow many levels below Path to list. The totals still count everything underneath, so -Depth 1 gives you the size of each top-level folder.
-IncludeFilesswitch—List each file under its folder as well. Big trees get long fast, so pair it with -Depth or -CsvPath.
-IncludeHiddenswitch—Count hidden and system files and folders too.
-CsvPathstring—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:\Shared

Just the top two levels. The totals still include everything below them.

.\Get-DirectoryReport.ps1 -Path D:\Shared -Depth 2

The ten biggest folders anywhere in the tree.

.\Get-DirectoryReport.ps1 -Path D:\Shared | Sort-Object SizeMB -Descending | Select-Object -First 10 RelativePath, TotalFiles, SizeMB

Folders with nothing in them at all, at any depth.

.\Get-DirectoryReport.ps1 -Path D:\Shared | Where-Object TotalFiles -eq 0 | Select-Object RelativePath

A full inventory with every file listed, saved for Excel.

.\Get-DirectoryReport.ps1 -Path D:\Shared -IncludeFiles -IncludeHidden -CsvPath .\shared-inventory.csv | Out-Null

What you'll see

Example outputvalues are illustrative
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

  1. Two listings, one pass each. Get-ChildItem -Recurse runs 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.
  2. 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 TotalFiles and SizeMB cover a whole subtree, while Files is only what sits directly in that folder. LastWriteTime is the newest file anywhere underneath, which is a quick way to spot folders nobody has touched in years.
  3. 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 Tree column indents two spaces per level. -Depth stops the walk, not the counting.
  4. Hand back objects. The default view shows four columns so it fits on screen. RelativePath, Folders, Level, LastWriteTime and FullName are all there for Select-Object, Where-Object and 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 TotalFiles per RelativePath. Any row that doesn't match is worth a look.
  • Find archive candidates. Filter on LastWriteTime older 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 Type Folder, and insert a treemap on RelativePath and SizeMB. 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.