A developer’s Mac disk audit: Xcode, Docker, node_modules, Rust, Gradle and Homebrew

Where Xcode, Docker, Node, Rust, Gradle and Homebrew keep their gigabytes on a Mac, how to clean each one safely, and a read-only script that sizes them all.

A Mac used for development fills up in places Finder doesn’t show: build folders in every project, package caches in hidden folders, and container disk images whose reported size isn’t what they use. Most of it rebuilds itself. Some of it doesn’t.

Below is each of those places: where it lives, how to measure it, what’s safe to delete and the tool’s own cleanup command. First, though, a short read-only script that measures all of them at once, so you clean what’s big on your Mac rather than what a generic guide lists.

The checklist

What Where Comes back? Clean up with
Xcode build data ~/Library/Developer/Xcode/DerivedData Yes, next build Delete a project’s folder
Xcode archives ~/Library/Developer/Xcode/Archives No Window > Organizer
Device symbols ~/Library/Developer/Xcode/iOS DeviceSupport Yes, on reconnect Delete old OS versions
Simulators ~/Library/Developer/CoreSimulator/Devices No xcrun simctl delete unavailable
Simulator runtimes Outside your home folder Re-downloadable Xcode > Settings > Components
Docker Desktop ~/Library/Containers/com.docker.docker Images yes, volumes no docker system prune
OrbStack ~/Library/Group Containers/HUAQ24HBR6.dev.orbstack Images yes, volumes no The same docker commands
node_modules Every JavaScript project Yes, on install Delete, then npm ci
npm, Yarn, pnpm ~/.npm, ~/Library/Caches/Yarn, ~/.yarn/berry, ~/Library/pnpm/store Re-downloaded npm cache clean --force, yarn cache clean, pnpm store prune
Rust builds target in every Rust project Yes, next build cargo clean
Cargo downloads ~/.cargo/registry, ~/.cargo/git Re-downloaded Automatic since Rust 1.88
Rust toolchains ~/.rustup/toolchains Reinstallable rustup toolchain uninstall
Gradle ~/.gradle/caches, ~/.gradle/wrapper/dists Re-downloaded Automatic, or delete with daemons stopped
Android emulators ~/Library/Android/sdk/system-images, ~/.android/avd Images yes, emulator data no SDK Manager, Device Manager
Homebrew ~/Library/Caches/Homebrew, old versions Downloads yes brew cleanup
CocoaPods, pip, uv, Go See the last section Re-downloaded Each tool’s cache command

“Comes back” means the tool recreates it when it next needs it, at the cost of time and bandwidth. “No” means deleting it loses something for good.

Measure first with one read-only script

Save this as dev-disk-audit.sh:

#!/bin/bash
# dev-disk-audit.sh: size the usual developer storage on a Mac, largest first.
# Read-only: it sizes things with du, find and test, and changes nothing.
#
#   bash dev-disk-audit.sh [projects folder]     (zsh dev-disk-audit.sh works too)
#
# The projects folder (default: your home folder) is searched for node_modules
# and Cargo target folders. Hidden folders, ~/Library and ~/go/pkg are skipped
# there. Sizes are space allocated on disk, in decimal GB and MB like Finder.

root=${1:-$HOME}
L=$HOME/Library

# Print "KB<tab>label<tab>path" if the path exists; stay silent if it doesn't.
measure() {
  if [ -e "$2" ]; then
    printf '%s\t%s\t%s\n' "$(du -sk "$2" 2>/dev/null | cut -f1)" "$1" "$2"
  fi
}

# One "KB<tab>path" line per node_modules folder, and per target folder that
# Cargo has tagged with CACHEDIR.TAG (other tools' target folders are left out).
projects=$(find "$root" -mindepth 1 \
  \( -name '.*' -o -path "$L" -o -path "$HOME/go/pkg" \) -prune -o \
  -type d -name node_modules -prune -exec du -sk {} + -o \
  -type d -name target -prune -exec test -f {}/CACHEDIR.TAG \; -exec du -sk {} + \
  2>/dev/null)

# Print size lines as a table: "KB<tab>label<tab>path" or "KB<tab>path".
table='function size(k) { b = k * 1024
    return b >= 1e9 ? sprintf("%7.1f GB", b / 1e9) : sprintf("%7.1f MB", b / 1e6) }
  function short(p) { return index(p, home) == 1 ? "~" substr(p, length(home) + 1) : p }
  NF < 2 { next }
  { sum += $1
    if (NF == 2) printf "%s  %s\n", size($1), short($2)
    else printf "%s  %-28s %s\n", size($1), $2, short($3) }
  END { if (total) printf "%s  Total measured\n", size(sum) }'

printf 'Developer storage, largest first\n\n'
{
  measure "Xcode DerivedData"          "$L/Developer/Xcode/DerivedData"
  measure "Xcode Archives"             "$L/Developer/Xcode/Archives"
  measure "iOS device support"         "$L/Developer/Xcode/iOS DeviceSupport"
  measure "watchOS device support"     "$L/Developer/Xcode/watchOS DeviceSupport"
  measure "Simulator devices"          "$L/Developer/CoreSimulator/Devices"
  measure "Simulator caches"           "$L/Developer/CoreSimulator/Caches"
  measure "Docker Desktop disk image"  "$L/Containers/com.docker.docker/Data/vms/0/data/Docker.raw"
  measure "OrbStack disk image"        "$L/Group Containers/HUAQ24HBR6.dev.orbstack/data/data.img"
  measure "OrbStack disk image"        "$L/Group Containers/HUAQ24HBR6.dev.orbstack/data/data.img.raw"
  measure "npm cache"                  "$HOME/.npm"
  measure "Yarn 1 cache"               "$L/Caches/Yarn"
  measure "Yarn 2+ global cache"       "$HOME/.yarn/berry/cache"
  measure "pnpm store"                 "$L/pnpm/store"
  measure "Cargo registry"             "$HOME/.cargo/registry"
  measure "Cargo git dependencies"     "$HOME/.cargo/git"
  measure "Rust toolchains"            "$HOME/.rustup/toolchains"
  measure "Gradle caches"              "$HOME/.gradle/caches"
  measure "Gradle wrapper downloads"   "$HOME/.gradle/wrapper/dists"
  measure "Android system images"      "$L/Android/sdk/system-images"
  measure "Android emulators (AVDs)"   "$HOME/.android/avd"
  measure "Homebrew downloads"         "$L/Caches/Homebrew"
  measure "Homebrew packages"          "/opt/homebrew/Cellar"
  measure "Homebrew packages"          "/usr/local/Cellar"
  measure "CocoaPods cache"            "$L/Caches/CocoaPods"
  measure "pip cache"                  "$L/Caches/pip"
  measure "uv cache"                   "$HOME/.cache/uv"
  measure "Go module cache"            "$HOME/go/pkg/mod"
  measure "Go build cache"             "$L/Caches/go-build"
  printf '%s\n' "$projects" | awk -F'\t' -v r="$root" '
    $2 ~ /\/node_modules$/ { n += $1; nc++ }
    $2 ~ /\/target$/       { t += $1; tc++ }
    END { if (nc) printf "%.0f\tnode_modules folders (%d)\t%s\n", n, nc, r
          if (tc) printf "%.0f\tRust target folders (%d)\t%s\n", t, tc, r }'
} | sort -rn | awk -F'\t' -v home="$HOME" -v total=1 "$table"

if [ -n "$projects" ]; then
  printf '\nLargest project folders\n\n'
  printf '%s\n' "$projects" | sort -rn | head -n 10 |
    awk -F'\t' -v home="$HOME" -v total=0 "$table"
fi

Then run it with the folder that holds your projects:

bash dev-disk-audit.sh ~/code

It runs under the zsh and bash 3.2 that come with macOS, installs nothing and only reads: du, find and test look at your files, and sort and awk print the table. Paths that don’t exist are skipped. Without a folder argument it searches your whole home folder, which is slower, and macOS may ask before Terminal reads Desktop, Documents or Downloads.

Some choices in it are deliberate:

  • Allocated space, not apparent size. Docker’s and OrbStack’s disk images are sparse files: ls -l shows what they could grow to, du shows what they occupy.
  • A target folder counts only if Cargo tagged it with CACHEDIR.TAG, a cross-tool convention meaning “everything in here can be regenerated”. Maven builds into target folders too, and those stay out of the Rust total.
  • OrbStack’s disk image is sized as a file, not by listing its folder. On macOS 15 and later, reading inside another app’s group container can trigger a permission prompt.

Two caveats. du counts a hard-linked file once per run but counts every APFS clone in full, and on macOS pnpm clones files from its store into node_modules by default, so read the total as an upper bound. And simulator runtimes live outside your home folder; the next section shows how to size them.

Xcode

DerivedData

~/Library/Developer/Xcode/DerivedData holds build products, intermediate files and logs, mostly one folder per project. Deleting a project’s folder costs a full rebuild and nothing else. Quit Xcode first. If you’ve moved it, Xcode > Settings > Locations shows where.

Archives

Product > Archive saves to ~/Library/Developer/Xcode/Archives, and nothing recreates an archive once it’s gone. Each one holds your app’s binaries and their dSYM files, and Apple’s documentation says to keep the archive for every build you distribute, or you may not be able to diagnose its crash reports. Review them in Window > Organizer, delete archives for builds that never shipped, and move the rest to another disk if you need the space.

Device support

When you connect an iPhone, iPad or Apple Watch, Xcode copies its system symbols into ~/Library/Developer/Xcode/iOS DeviceSupport (or watchOS DeviceSupport, and so on) so it can symbolicate system frameworks in crash reports. Each OS release and device model gets its own set.

Sets for versions you no longer test against are reasonable to delete. Xcode copies them again when a device on that version connects, and xcodebuild -prepareDeviceSupport can fetch them on request. Keep any you need for symbolicating your users’ crash reports.

Simulators and runtimes

Each simulator keeps its installed apps and data in ~/Library/Developer/CoreSimulator/Devices, and deleting a simulator deletes everything in it. List first:

xcrun simctl list devices
xcrun simctl delete unavailable

The second command removes only the simulators your current Xcode SDK doesn’t support.

Runtimes, the system images that simulators boot, are stored outside your home folder, so ask simctl about them:

xcrun simctl runtime list
xcrun simctl runtime delete <identifier>

list ends with their total size. Xcode > Settings > Components does the same job (click the info button next to a runtime, then Delete) and can download older runtimes again. Run xcrun simctl runtime on its own to see what your Xcode version supports; current versions can preview a cleanup with delete --notUsedSinceDays <days> --dry-run.

Docker Desktop and OrbStack

Start with Docker’s own accounting:

docker system df
docker system df -v

It splits usage into images, containers, local volumes and build cache, with a RECLAIMABLE column for space nothing is using; -v lists every item. Then prune, narrowest first: docker builder prune removes only the build cache, docker image prune -a removes every image no container uses, and docker system prune removes stopped containers, unused networks, dangling images and the build cache. Each asks for confirmation unless you add -f:

docker builder prune
docker image prune -a
docker system prune

Images and build cache come back when you pull or build again. Stopped containers don’t, and neither does anything you changed inside them. Volumes are the real risk: they hold databases and other container state, and removing them is permanent. The commands above leave volumes alone, but docker system prune --volumes adds anonymous volumes, and docker volume prune -a also removes named volumes that no container is using. Check docker volume ls first.

Docker Desktop’s disk image

Docker Desktop keeps all of this in one sparse file, Docker.raw, in ~/Library/Containers/com.docker.docker/Data/vms/0/data. Settings > Resources > Advanced shows its location, its maximum size and what it actually uses, while some tools report only the maximum. In Terminal:

ls -klsh ~/Library/Containers/com.docker.docker/Data/vms/0/data/Docker.raw

The first column is the space it occupies; the size column further along is the maximum.

Does it shrink? Docker’s current documentation says deleting images frees space on your Mac, though it can take a few minutes to show, while deleting files inside a running container doesn’t free it automatically. To reclaim space on demand, Docker documents this command:

docker run --privileged --pid=host docker/desktop-reclaim-space

Don’t move or delete Docker.raw in Finder, because Docker Desktop can lose track of it; change its location in Settings instead. And lowering the Disk image size slider deletes the current image, with every container and image inside.

OrbStack

OrbStack keeps images, containers and Linux machines in a sparse disk image (data.img or data.img.raw) in ~/Library/Group Containers/HUAQ24HBR6.dev.orbstack/data. Its documentation explains that the file can show an apparent size of 8 TB while using only what you store, and that it shrinks automatically when you delete data. du -h on the file, or “size on disk” in Finder’s Get Info window, gives the real figure.

OrbStack creates a Docker context called orbstack, so the commands above work against it. If you run more than one engine, docker context ls shows which one your commands reach, and docker --context orbstack system df asks OrbStack directly.

node_modules and package caches

Every JavaScript project carries its own node_modules. To list them by size:

find ~/code -type d -name node_modules -prune -exec du -sh {} + | sort -h

-prune stops find from descending into each one, so nested node_modules aren’t counted twice. Deleting a project’s node_modules is safe while its package.json and lockfile are intact: npm ci reinstalls exactly what package-lock.json specifies, and pnpm install or yarn install rebuild it for those tools.

The shared caches:

  • npm keeps its cache in ~/.npm (npm config get cache confirms it). npm’s documentation calls the cache self-healing and says clearing it should only be needed to reclaim disk space. npm cache verify garbage-collects data that isn’t needed; npm cache clean --force empties it.
  • Yarn 1: yarn cache dir prints the location, under ~/Library/Caches/Yarn on a Mac, and yarn cache clean empties it. Yarn 2 and later keep a global cache in ~/.yarn/berry/cache by default, and yarn cache clean --mirror clears it.
  • pnpm keeps one content-addressable store, ~/Library/pnpm/store on macOS (pnpm store path prints it). pnpm store prune removes packages no project references. pnpm’s documentation calls that harmless but suggests not running it too often, since switching to an older branch may need those packages again.

Rust

Every Cargo workspace builds into a target folder at its root. cargo clean deletes it, and the next build compiles everything again; in current Cargo, cargo clean --dry-run shows what would go. To find every Rust build folder without catching other tools’ target folders, look for Cargo’s tag:

find ~/code -type d -name target -prune -exec test -f {}/CACHEDIR.TAG \; -exec du -sh {} + | sort -h

Cargo’s shared downloads live in ~/.cargo/registry (the index, downloaded .crate archives in cache and unpacked sources in src) and ~/.cargo/git. The Cargo Book says you can remove any part of this cache and Cargo will restore what a build needs. Since Rust 1.88, Cargo also cleans it on its own, removing files downloaded from the network after three months without use and files from your own system after one month. Leave ~/.cargo/bin alone: it holds the tools you installed with cargo install.

Installed toolchains live in ~/.rustup/toolchains. rustup toolchain list shows them, and rustup toolchain uninstall <name> removes old nightlies or pinned versions you no longer build with.

Gradle and Android

Gradle’s user home, ~/.gradle, keeps downloaded dependencies and build caches in caches and wrapper distributions in wrapper/dists. Gradle prunes these itself: by default it checks every 24 hours and removes entries unused for 30 days (downloaded resources, released wrapper distributions) or 7 days (created resources, the build cache).

In a project, ./gradlew clean deletes the build directory. To clear the shared cache entirely, run ./gradlew --stop to stop that Gradle version’s daemons, then delete ~/.gradle/caches. The next build downloads its dependencies again, and offline builds fail until it has.

Android emulators use two kinds of storage:

  • System images live in the SDK, by default ~/Library/Android/sdk/system-images. In Android Studio’s SDK Manager (Tools > SDK Manager), which also shows the SDK’s real location, clear the checkbox next to an image you no longer use and click Apply. You can download it again later.
  • Each emulator’s own data, its installed apps and settings, lives in ~/.android/avd. In Device Manager, an emulator’s ⋮ menu has Wipe Data, which resets it to new, and Delete, which removes it. Both are permanent.

Homebrew

brew --cache prints the download cache, ~/Library/Caches/Homebrew by default. brew cleanup removes old versions of installed formulae, stale lock files and downloads older than 120 days (HOMEBREW_CLEANUP_MAX_AGE_DAYS changes that). Preview it first with -n, which only shows what would be removed. --prune=all removes all cached files, whatever their age, and brew autoremove -n lists dependencies nothing needs any more:

brew cleanup -n
brew cleanup
brew cleanup --prune=all
brew autoremove -n

Unless HOMEBREW_NO_INSTALL_CLEANUP is set, Homebrew already cleans up the packages involved after brew install, upgrade and reinstall, and everything every 30 days. brew autoremove without -n uninstalls formulae that were only installed as dependencies and are no longer needed. Installed packages live in $(brew --prefix)/Cellar; remove them with brew uninstall, not by deleting folders.

Other caches worth checking

  • CocoaPods: ~/Library/Caches/CocoaPods. pod cache list shows what’s there, pod cache clean --all empties it, and the next pod install downloads what it needs.
  • pip: pip cache dir (~/Library/Caches/pip on macOS), pip cache info for its size and pip cache purge to empty it. pip’s documentation says the cache layout can change between versions, so use these commands rather than deleting files by hand.
  • uv: uv cache dir (~/.cache/uv), uv cache prune for unused entries and uv cache clean for everything.
  • Go: the module cache is go env GOMODCACHE (~/go/pkg/mod by default) and the build cache is go env GOCACHE (~/Library/Caches/go-build on a Mac). Go creates module cache folders read-only, so rm -r fails part-way; use go clean -modcache. Go trims the build cache itself, and go clean -cache empties it.

Before you delete anything

Moving something to the Trash in Finder frees its space only when you empty the Trash, while rm -rf and each tool’s own prune and clean commands are permanent. Archives, Docker volumes and stopped containers, simulator and emulator data, and ~/.cargo/bin don’t come back. Everything else here rebuilds or downloads again, at the cost of time and bandwidth, so the caches for this week’s projects are usually worth keeping.

Disk Whisk, the Mac app behind this blog, shows the same kind of breakdown without a terminal, including what OrbStack, Docker Desktop, Colima and Lima hold, and clears containers through each engine’s own tools rather than by deleting its disk image.