Reclaim 100+ GB from Xcode: The Cleanup Script I Wish I'd Written Years Ago

I opened a disk-space tool the other day because my MacBook Pro was at 959 GB of 995 GB used. Xcode and iOS Simulator were the obvious suspects, but the scale of it surprised me: 98 GB in CoreSimulator, 107 GB in AssetsV2, and 125 GB across 19 installed simulator runtimes. Most of that bloat was invisible — spread across three different directories, with runtimes for iOS versions I hadn't touched in a year.
I've cleaned this stuff before with the usual rm -rf DerivedData one-liners. Those don't touch the biggest offenders. This is the script I wish I'd written years ago.

Where Xcode Actually Hides Your Disk Space

There are three locations that matter, and they compound over time:
DerivedData and caches — the usual suspects. ~/Library/Developer/Xcode/DerivedData, ~/Library/Caches/com.apple.dt.Xcode, package manager caches. These rebuild on next build. Safe to nuke.
CoreSimulator~/Library/Developer/CoreSimulator. Contains every simulator device you've ever created, their app data, logs, and a Caches folder that grows unchecked. On my machine this was 98 GB. xcrun simctl erase all clears device contents but keeps the device definitions.
AssetsV2/System/Library/AssetsV2. This is the one nobody talks about. On modern Xcode, simulator runtime disk images live here instead of CoreSimulator. I had 86.3 GB of iOS simulator runtimes and 13.5 GB of watchOS runtimes in this system-managed directory. You cannot rm -rf this folder — it's SIP-protected, and you shouldn't want to. The right way in is xcrun simctl runtime delete.
Between all three, my machine had simulator runtimes going back to iOS 17.0.1 that I last used in January 2026 and will never use again. Each iOS runtime is 7-8 GB. Each watchOS runtime is 3.5-4.5 GB. Do the math on a few years of Xcode updates and you get hundreds of gigabytes.

The Script

This does the whole sweep. It quits Xcode and Simulator first (important — erase all on a booted device leaves things in a half-broken state), clears DerivedData and caches, deletes unused device support files, erases simulator contents, clears package manager caches, removes unusable runtimes, then presents a numbered menu of remaining runtimes with all, old, or individual-selection options.
#!/usr/bin/env bash
# Xcode & iOS Simulator cleanup
# Reclaims disk space from DerivedData, caches, and simulator bloat.

set -u

echo "🧹 Xcode cleanup starting..."
echo

BEFORE=$(df -k / | awk 'NR==2 {print $4}')

echo "→ Quitting Xcode and Simulator..."
osascript -e 'tell application "Xcode" to quit' 2>/dev/null
osascript -e 'tell application "Simulator" to quit' 2>/dev/null
sleep 2

echo "→ Removing DerivedData..."
rm -rf ~/Library/Developer/Xcode/DerivedData
rm -rf ~/Library/Developer/Xcode/DerivedData/ModuleCache.noindex

echo "→ Removing Xcode caches..."
rm -rf ~/Library/Caches/com.apple.dt.Xcode
rm -rf ~/Library/Caches/com.apple.dt.Instruments

echo "→ Removing old iOS DeviceSupport..."
rm -rf ~/Library/Developer/Xcode/iOS\ DeviceSupport
rm -rf ~/Library/Developer/Xcode/watchOS\ DeviceSupport
rm -rf ~/Library/Developer/Xcode/tvOS\ DeviceSupport

echo "→ Deleting simulators tied to missing runtimes..."
xcrun simctl delete unavailable

echo "→ Erasing all simulator content and settings..."
xcrun simctl shutdown all 2>/dev/null
xcrun simctl erase all

echo "→ Clearing CoreSimulator caches..."
rm -rf ~/Library/Developer/CoreSimulator/Caches/*

echo "→ Clearing package manager caches..."
rm -rf ~/Library/Caches/org.swift.swiftpm
rm -rf ~/Library/Developer/Xcode/DerivedData/SourcePackages
rm -rf ~/Library/Caches/CocoaPods

echo "→ Removing unusable simulator runtimes..."
xcrun simctl runtime delete unusable 2>/dev/null || true

# ─── Interactive runtime cleanup ────────────────────────────────────────
echo
echo "→ Scanning installed simulator runtimes..."

RUNTIME_TEXT=$(xcrun simctl runtime list -v 2>/dev/null)

if [ -z "${RUNTIME_TEXT}" ]; then
  echo "   No runtimes found. Skipping."
else
  PARSED=$(python3 <<PYEOF
import re
text = """${RUNTIME_TEXT}"""
header_re = re.compile(r'^(iOS|watchOS|tvOS|visionOS)\s+([\d.]+)\s+\(([^)]+)\)\s+-\s+([A-F0-9\-]{36})\s*$', re.M)
matches = list(header_re.finditer(text))
entries = []
for i, m in enumerate(matches):
    platform, version, build, uuid = m.group(1), m.group(2), m.group(3), m.group(4)
    start = m.end()
    end = matches[i+1].start() if i+1 < len(matches) else len(text)
    block = text[start:end]
    size_m = re.search(r'Size:\s*(\S+)', block)
    last_m = re.search(r'Last Used At:\s*(\S+)', block)
    size = size_m.group(1) if size_m else "?"
    last = last_m.group(1) if last_m else "never"
    entries.append((platform, version, build, uuid, size, last))
platform_order = {"iOS": 0, "watchOS": 1, "tvOS": 2, "visionOS": 3}
def version_key(v):
    return tuple(int(x) for x in v.split('.'))
entries.sort(key=lambda e: (platform_order.get(e[0], 9), version_key(e[1])))
for p, v, b, u, s, l in entries:
    print(f"{p}|{v}|{b}|{u}|{s}|{l}")
PYEOF
)

  if [ -z "${PARSED}" ]; then
    echo "   Could not parse runtime list. Skipping."
  else
    echo
    printf "  %-3s %-9s %-9s %-9s %-6s %-12s\n" "#" "Platform" "Version" "Build" "Size" "Last Used"
    echo "  ───────────────────────────────────────────────────────────"

    UUIDS=()
    PLATFORMS=()
    LASTUSED=()
    i=1
    while IFS='|' read -r platform version build uuid size last_used; do
      last_short="${last_used:0:10}"
      printf "  %-3s %-9s %-9s %-9s %-6s %-12s\n" "$i" "$platform" "$version" "$build" "$size" "$last_short"
      UUIDS+=("$uuid")
      PLATFORMS+=("$platform")
      LASTUSED+=("$last_used")
      i=$((i+1))
    done <<< "${PARSED}"

    TOTAL=${#UUIDS[@]}
    echo
    echo "  Options:"
    echo "    • Numbers separated by spaces (e.g. '1 3 5 7')"
    echo "    • 'all'  — delete ALL runtimes"
    echo "    • 'old'  — keep the 2 most recently used iOS runtimes, delete the rest"
    echo "    • Enter to skip"
    echo
    read -p "Your choice: " CHOICE

    DELETE_INDICES=()

    if [ "${CHOICE}" = "all" ]; then
      for ((j=0; j<TOTAL; j++)); do DELETE_INDICES+=("$j"); done
    elif [ "${CHOICE}" = "old" ]; then
      KEEP_INDICES=$(python3 <<PYEOF
entries = []
$(for ((j=0; j<TOTAL; j++)); do
    echo "entries.append(($j, '${PLATFORMS[$j]}', '${LASTUSED[$j]}'))"
  done)
ios = [(idx, last) for idx, plat, last in entries if plat == 'iOS' and last != 'never']
ios.sort(key=lambda x: x[1], reverse=True)
for idx, _ in ios[:2]:
    print(idx)
PYEOF
)
      for ((j=0; j<TOTAL; j++)); do
        if ! echo "${KEEP_INDICES}" | grep -qx "$j"; then
          DELETE_INDICES+=("$j")
        fi
      done
    elif [ -n "${CHOICE}" ]; then
      for num in ${CHOICE}; do
        if [[ "${num}" =~ ^[0-9]+$ ]] && [ "${num}" -ge 1 ] && [ "${num}" -le "${TOTAL}" ]; then
          DELETE_INDICES+=("$((num-1))")
        else
          echo "   ⚠️  Ignoring invalid selection: ${num}"
        fi
      done
    fi

    if [ "${#DELETE_INDICES[@]}" -gt 0 ]; then
      echo
      echo "→ Deleting ${#DELETE_INDICES[@]} runtime(s)..."
      for idx in "${DELETE_INDICES[@]}"; do
        uuid="${UUIDS[$idx]}"
        label="${PLATFORMS[$idx]} (${uuid:0:8}...)"
        echo "   • ${label}"
        if ! xcrun simctl runtime delete "${uuid}" 2>&1 | sed 's/^/     /'; then
          echo "     ⚠️  Failed — see message above"
        fi
      done
    else
      echo "   Skipped runtime deletion."
    fi
  fi
fi

AFTER=$(df -k / | awk 'NR==2 {print $4}')
RECLAIMED=$(( (AFTER - BEFORE) / 1024 / 1024 ))

echo
echo "✅ Done. Approximately ${RECLAIMED} GB reclaimed."
echo "   Next Xcode launch will be slower while indexes rebuild."
Save this as clean_xcode.sh, chmod +x clean_xcode.sh, and run it.

The Gotcha That Burned Me

My first version of this script tried to pass reverse-DNS identifiers like com.apple.CoreSimulator.SimRuntime.iOS-26-4 to xcrun simctl runtime delete. Every deletion failed silently.
The command wants a disk image UUID, not the runtime identifier — the hex string like 268F3190-3917-481F-B3B8-24B29EC60B7F from the first column of xcrun simctl runtime list. The field in the JSON output misleadingly named runtimeIdentifier is not what you pass. The script above parses the text output with regex and captures the UUID, which is what actually works.
If you write your own version, pipe the delete stderr to your terminal. Silent failures will cost you an hour.

The 'old' Preset Is What You Actually Want

When the menu appears, type old. It keeps your two most recently used iOS runtimes and deletes everything else — old iOS betas, orphaned watchOS versions, duplicate point releases from Xcode updates. That one command did the bulk of the work for me.
If you don't build watchOS apps, go further and select the watchOS rows by number too. Each one is 3.5-4.5 GB and there's no reason to keep them.
If you're between projects and willing to re-download the current iOS runtime, type all. Xcode will fetch it fresh on next launch. This reclaims everything.

What This Doesn't Touch

A few deliberate omissions worth knowing about:
The full CoreSimulator directory. rm -rf ~/Library/Developer/CoreSimulator is the scorched-earth option. It forces you to re-download every runtime from Xcode → Settings → Platforms. The combination of delete unavailable + erase all + clearing Caches gets you most of the way there without that pain.
Xcode archives. ~/Library/Developer/Xcode/Archives contains the .xcarchive bundles you need to symbolicate crash reports from App Store builds. Don't auto-delete these. Clean them manually in Xcode → Window → Organizer.
Homebrew, npm, Bundler caches. Out of scope here. Worth a separate cleanup pass if you're really scraping for space.

Run It Quarterly

I've added this to my quarterly maintenance routine alongside dependency updates and credential rotation. Xcode doesn't prune old runtimes on its own — each update adds to the pile. Running this script every three months keeps the bloat from ever getting to the 100+ GB range again.
If you're on a disk-space-constrained MacBook and haven't cleaned Xcode in a year, you're probably sitting on 50-150 GB you could have back by tonight.