Xcode keeps one DerivedData folder per project path and writes that path into the folder's info.plist. Every Git worktree has its own path, so every worktree gets its own build folder, and git worktree remove leaves that folder behind. To clean up safely, read the recorded path in each info.plist, check whether it still exists, and delete only the folders whose path is gone. Matching on the project name deletes the build data of worktrees you are still using.
Quick Take
bash derived-data-report.sh --prefix ~/code/myapp lists each DerivedData folder for that checkout and its myapp-* worktrees, with branch, size and last use, and marks folders whose worktree no longer exists as MISSING. Add --delete-missing to remove only those, after quitting Xcode.
What Xcode creates for each worktree
We built a small Swift package in its main checkout and in a worktree, then listed the DerivedData folders it produced and the WorkspacePath each one recorded:
$ git worktree list
/private/tmp/xcode-worktree-demo/RecipeKit 7798c55 [main]
/private/tmp/xcode-worktree-demo/RecipeKit-feature 7798c55 [feature]
# after building in both checkouts, the DerivedData folders and the path each recorded:
RecipeKit-capvayofxxjhsyfejhfaeucrpbmn /tmp/xcode-worktree-demo/RecipeKit
RecipeKit-bkvhqqtjhkeikvajdslxdappvlyn /tmp/xcode-worktree-demo/RecipeKit-feature
RecipeKit-feature-bkvhqqtjhkeikvajdslxdappvlyn (no info.plist)Four details from that test matter for any cleanup script:
- Every worktree's folder starts with the same name. The prefix is the project or package name; only the 28-letter hash differs. A
RecipeKit-*wildcard would delete the main checkout's build data along with the removed worktree's. - The project path is in info.plist. It also records when Xcode last used the folder:
$ plutil -p ~/Library/Developer/Xcode/DerivedData/RecipeKit-bqfjovskduzgwqeyvymqootabnca/info.plist
{
"LastAccessedDate" => 2026-11-12 00:40:57 +0000
"WorkspacePath" => "/tmp/xcode-recipes-demo/RecipeKit-feature"
}- A worktree can leave a second folder without an info.plist. Building in the worktree also created
RecipeKit-feature-<hash>, named after the worktree's directory and sharing the hash of the recorded folder. It has no info.plist, so a script that only reads info.plist files misses it. - Paths under /private are recorded without the prefix. The worktree lived in
/private/tmpbut Xcode stored/tmp. Compare both forms before calling a path missing.
Xcode 27 also keeps shared caches at the top of DerivedData that belong to no project, such as SDKExplicitPrecompiledModules, ModuleCache.noindex, SDKStatCaches.noindex and CompilationCache.noindex. They have no info.plist either and should never be treated as orphans.
The report labels them separately. From our test Mac:
20 KB no info.plist - Unsaved_Xcode_Document_5-hdhtdidjkkmijrhawizaxvvtoskv
15 MB shared cache (keep) - SDKStatCaches.noindex
532 KB shared cache (keep) - ModuleCache.noindex
400 KB shared cache (keep) - SDKExplicitPrecompiledModules
28 KB shared cache (keep) - CompilationCache.noindexMap DerivedData folders to worktrees
The derived-data-report.sh script reads every DerivedData folder, follows its recorded path and reports one of these states:
| State | Meaning | Action |
|---|---|---|
main:<branch> | The path is in a repository's main checkout | Keep |
worktree:<branch> | The path is in a linked worktree that still exists | Keep while you use it |
exists | The path exists but is not in a Git repository | Keep |
MISSING | The recorded path is gone, for example after git worktree remove | Safe to delete |
companion in the last-used column | A folder without info.plist that shares its hash with a recorded folder; it inherits that folder's state | Follows the recorded folder |
shared cache (keep) | A top-level Xcode cache with no project | Keep |
no info.plist | A project-style folder with no recorded path and no recorded sibling | Check by hand |
It checks the default location, a custom location set in Xcode Settings > Locations, every DerivedData root listed in Xcode's own ~/Library/Developer/Xcode/.knownDerivedDataLocations.log, and any folder you pass with --root. That log is undocumented, so treat it as a hint. On our Mac it listed the default folder (user name replaced):
$ head -c 400 ~/Library/Developer/Xcode/.knownDerivedDataLocations.log
{
"derivedDataDirectories" : [
"/Users/you/Library/Developer/Xcode/DerivedData",
{
"accessDate" : "2026-11-06T06:15:41Z",
"appBundlePath" : "/Applications/Xcode.app",
...A repository with one worktree, before it is removed:
$ bash derived-data-report.sh --prefix /tmp/xcode-worktree-demo
SIZE PROJECT PATH LAST USED DERIVED DATA FOLDER -> PROJECT
4 MB main:main Nov 12 2026 RecipeKit-capvayofxxjhsyfejhfaeucrpbmn
-> /tmp/xcode-worktree-demo/RecipeKit
2 MB worktree:feature Nov 12 2026 RecipeKit-bkvhqqtjhkeikvajdslxdappvlyn
-> /tmp/xcode-worktree-demo/RecipeKit-feature
1 MB worktree:feature companion RecipeKit-feature-bkvhqqtjhkeikvajdslxdappvlyn
-> /tmp/xcode-worktree-demo/RecipeKit-feature
0 folder(s) point to a project path that no longer exists (0 KB).
Nothing was deleted. Re-run with --delete-missing to remove those folders. The full script. Copy it, type pbpaste > derived-data-report.sh in Terminal to save it, then run it from any folder:
#!/bin/bash
# derived-data-report.sh
# Maps each Xcode DerivedData folder to the project or Git worktree that
# created it, shows its size and last use, and flags folders whose project
# path no longer exists (for example after `git worktree remove`).
#
# Read-only by default. With --delete-missing it removes only folders whose
# recorded project path is gone, after showing each one and asking first.
#
# Usage: bash derived-data-report.sh
# bash derived-data-report.sh --root /path/to/custom/DerivedData
# bash derived-data-report.sh --prefix ~/code/myapp
# bash derived-data-report.sh --delete-missing [--yes]
# Guide: https://appscreens.com/blog/xcode-derived-data-git-worktrees
# Tested: macOS 26.7, Xcode 27.2 (bash 3.2).
# SPDX-License-Identifier: MIT
# Copyright (c) 2026 AppScreens. Free to use, copy and change; please keep
# this header, including the guide link.
set -u
ROOTS=""; PREFIX=""; DELETE=0; YES=0
while [ $# -gt 0 ]; do
case "$1" in
--root) ROOTS="$ROOTS
$2"; shift ;;
--prefix) PREFIX=${2%/}; shift ;;
--delete-missing) DELETE=1 ;;
--yes) YES=1 ;;
-h|--help) sed -n '2,16p' "$0"; exit 0 ;;
*) echo "Unknown option: $1" >&2; exit 1 ;;
esac
shift
done
# DerivedData roots: the default, a custom location set in Xcode settings,
# roots recorded in Xcode's own location log, and any --root you pass.
DEFAULT="$HOME/Library/Developer/Xcode/DerivedData"
CUSTOM=$(defaults read com.apple.dt.Xcode IDECustomDerivedDataLocation 2>/dev/null || true)
KNOWN=$(grep -o '"/[^"]*"' "$HOME/Library/Developer/Xcode/.knownDerivedDataLocations.log" 2>/dev/null | tr -d '"' | grep -v '\.app$' || true)
ROOTS=$(printf "%s\n%s\n%s\n%s\n" "$DEFAULT" "$CUSTOM" "$KNOWN" "$ROOTS" | sed 's:/*$::' | grep -v '^$' | sort -u)
human() { awk -v k="$1" 'BEGIN { if (k >= 1048576) printf "%.1f GB", k / 1048576; else if (k >= 1024) printf "%.0f MB", k / 1024; else printf "%d KB", k }'; }
# Xcode records some paths without the /private prefix (/tmp, /var).
exists() { [ -e "$1" ] || [ -e "/private$1" ]; }
describe() { # what the recorded project path is today
local wp=$1 dir top gd cd
if ! exists "$wp"; then echo "MISSING"; return; fi
case "$wp" in
*.xcodeproj|*.xcworkspace|*.playground) dir=$(dirname "$wp") ;;
*) dir=$wp ;; # Swift packages record the package folder itself
esac
[ -d "$dir" ] || dir="/private$dir"
top=$(git -C "$dir" rev-parse --show-toplevel 2>/dev/null) || { echo "exists"; return; }
gd=$(cd "$dir" && cd "$(git rev-parse --git-dir)" && pwd -P)
cd=$(cd "$dir" && cd "$(git rev-parse --git-common-dir)" && pwd -P)
if [ "$gd" != "$cd" ]; then
echo "worktree:$(git -C "$dir" branch --show-current 2>/dev/null)"
else
echo "main:$(git -C "$dir" branch --show-current 2>/dev/null)"
fi
}
TMP=$(mktemp -t derived-data-report)
trap 'rm -f "$TMP" "$TMP.2"' EXIT
echo "$ROOTS" | while read -r root; do
[ -d "$root" ] || continue
for d in "$root"/*/; do
d=${d%/}
[ -d "$d" ] || continue
wp=""; [ -f "$d/info.plist" ] && wp=$(/usr/libexec/PlistBuddy -c 'Print :WorkspacePath' "$d/info.plist" 2>/dev/null)
if [ -z "$wp" ]; then
if [[ $(basename "$d") =~ -[a-z]{28}$ ]]; then state="no info.plist"; else state="shared cache (keep)"; fi
last="-"
else
state=$(describe "$wp")
last=$(/usr/libexec/PlistBuddy -c 'Print :LastAccessedDate' "$d/info.plist" 2>/dev/null | awk '{print $2, $3, $6}')
fi
k=$(du -sk "$d" 2>/dev/null | cut -f1)
printf "%s\t%s\t%s\t%s\t%s\n" "$k" "$state" "$last" "$d" "${wp:--}" >> "$TMP"
done
done
# A folder without info.plist that shares its 28-letter hash with a recorded
# folder belongs to the same project (Xcode can create both for one worktree).
# Then keep only projects under --prefix, if one was given.
awk -F'\t' -v OFS='\t' -v prefix="$PREFIX" '
function hash(path, n) { n = path; sub(/.*\//, "", n); sub(/.*-/, "", n); return n }
NR == FNR { if ($5 != "-") { st[hash($4)] = $2; wp[hash($4)] = $5 } next }
{
h = hash($4)
if ($2 == "no info.plist" && length(h) == 28 && (h in st)) { $2 = st[h]; $3 = "companion"; $5 = wp[h] }
if (prefix != "") {
p2 = prefix; sub(/^\/private/, "", p2)
if ($5 == "-" || (index($5, prefix) != 1 && index($5, p2) != 1 && index($5, "/private" p2) != 1)) next
}
print
}' "$TMP" "$TMP" > "$TMP.2" && mv "$TMP.2" "$TMP"
if [ ! -s "$TMP" ]; then echo "No DerivedData folders found."; exit 0; fi
printf "%-9s %-26s %-12s %s\n" "SIZE" "PROJECT PATH" "LAST USED" "DERIVED DATA FOLDER -> PROJECT"
sort -t$'\t' -k2,2 -k1,1rn "$TMP" | while IFS=$'\t' read -r k state last d wp; do
printf "%-9s %-26s %-12s %s\n" "$(human "$k")" "$state" "$last" "$(basename "$d")"
[ "$wp" != "-" ] && printf "%-9s %-26s %-12s -> %s\n" "" "" "" "$wp"
done
miss=$(awk -F'\t' '$2 == "MISSING" { n++; k += $1 } END { printf "%d %d", n, k }' "$TMP")
printf "\n%s folder(s) point to a project path that no longer exists (%s).\n" "${miss% *}" "$(human "${miss#* }")"
[ $DELETE -eq 1 ] || { echo "Nothing was deleted. Re-run with --delete-missing to remove those folders."; exit 0; }
if pgrep -xq Xcode; then echo "Xcode is running. Quit Xcode first so it does not recreate or hold these folders." >&2; exit 1; fi
awk -F'\t' '$2 == "MISSING" { print $4 }' "$TMP" | while read -r d; do
case "$(basename "$d")" in *-[a-z]*) ;; *) echo "Skipping unexpected name: $d"; continue ;; esac
if [ $YES -eq 0 ]; then
printf "Delete %s? [y/N] " "$d"; read -r answer < /dev/tty
[ "$answer" = "y" ] || { echo "kept"; continue; }
fi
rm -rf "$d" && echo "deleted $d"
doneClean up after removing a worktree
- Remove the worktree with
git worktree remove ../myapp-feature. If you deleted its folder by hand, rungit worktree pruneso Git forgets it. - Quit Xcode. The script refuses to delete while Xcode is running, so Xcode cannot recreate or hold a folder mid-cleanup.
- Report the repository with
--prefixset to the start of its path. It is a plain string match, so~/code/myappalso covers worktrees such as~/code/myapp-feature. Only MISSING folders are ever deleted, so a wider prefix is safe. - Delete only MISSING folders with
--delete-missing. Without--yesit asks for each folder. - Verify by running the report again.
git worktree remove ../myapp-feature
bash derived-data-report.sh --prefix ~/code/myappThen delete, answering y for each folder you want gone:
bash derived-data-report.sh --prefix ~/code/myapp --delete-missingAfter git worktree remove, the worktree's folder and its companion are MISSING and the main checkout is untouched:
$ git worktree remove ../RecipeKit-feature
$ bash derived-data-report.sh --prefix /tmp/xcode-worktree-demo
SIZE PROJECT PATH LAST USED DERIVED DATA FOLDER -> PROJECT
4 MB main:main Nov 12 2026 RecipeKit-capvayofxxjhsyfejhfaeucrpbmn
-> /tmp/xcode-worktree-demo/RecipeKit
2 MB MISSING Nov 12 2026 RecipeKit-bkvhqqtjhkeikvajdslxdappvlyn
-> /tmp/xcode-worktree-demo/RecipeKit-feature
1 MB MISSING companion RecipeKit-feature-bkvhqqtjhkeikvajdslxdappvlyn
-> /tmp/xcode-worktree-demo/RecipeKit-feature
2 folder(s) point to a project path that no longer exists (4 MB).
Nothing was deleted. Re-run with --delete-missing to remove those folders.With Xcode still open, the script stops before deleting anything:
$ bash derived-data-report.sh --prefix /tmp/xcode-worktree-demo --delete-missing --yes
...
2 folder(s) point to a project path that no longer exists (4 MB).
Xcode is running. Quit Xcode first so it does not recreate or hold these folders.With Xcode closed, it deletes only the MISSING folders. In this example both checkouts of the repository had been deleted:
$ bash derived-data-report.sh --prefix /tmp/xcode-recipes-demo --delete-missing --yes
SIZE PROJECT PATH LAST USED DERIVED DATA FOLDER -> PROJECT
4 MB MISSING Nov 12 2026 RecipeKit-gtludwkteqprjcdonxdqhuhzznbd
-> /tmp/xcode-recipes-demo/RecipeKit
2 MB MISSING Nov 12 2026 RecipeKit-bqfjovskduzgwqeyvymqootabnca
-> /tmp/xcode-recipes-demo/RecipeKit-feature
2 folder(s) point to a project path that no longer exists (6 MB).
deleted /Users/you/Library/Developer/Xcode/DerivedData/RecipeKit-bqfjovskduzgwqeyvymqootabnca
deleted /Users/you/Library/Developer/Xcode/DerivedData/RecipeKit-gtludwkteqprjcdonxdqhuhzznbd
$ bash derived-data-report.sh --prefix /tmp/xcode-recipes-demo
No DerivedData folders found.Without the script
This loop prints every DerivedData folder whose recorded project path no longer exists. It deletes nothing; review the list first:
for p in ~/Library/Developer/Xcode/DerivedData/*/info.plist; do
wp=$(/usr/libexec/PlistBuddy -c 'Print :WorkspacePath' "$p" 2>/dev/null) || continue
[ -e "$wp" ] || [ -e "/private$wp" ] || echo "$(dirname "$p") -> $wp"
done To find the folder for the checkout you are in, search the info.plist files for its path: grep -l "$PWD" ~/Library/Developer/Xcode/DerivedData/*/info.plist. Worktrunk's DerivedData recipe uses the same idea in a post-remove hook. A plain substring match also matches longer paths that start the same way, such as myapp-feature and myapp-feature-2, so compare the recorded path exactly before deleting, and look for a companion folder with the same hash.
Both shortcuts have gaps we hit in testing:
# the loop lists the recorded folder of the removed worktree...
/Users/you/Library/Developer/Xcode/DerivedData/RecipeKit-bkvhqqtjhkeikvajdslxdappvlyn -> /tmp/xcode-worktree-demo/RecipeKit-feature
# ...but not its companion, RecipeKit-feature-bkvhqqtjhkeikvajdslxdappvlyn, which has no info.plist.
$ cd /private/tmp/xcode-worktree-demo/RecipeKit-feature
$ grep -l "$PWD" ~/Library/Developer/Xcode/DerivedData/*/info.plist
$ echo $?
1
# no match: Xcode recorded /tmp/..., the shell reports /private/tmp/...Keep build data inside each worktree
If you build from the command line or a coding agent does, point DerivedData inside the worktree. Removing the worktree then removes its build data too:
$ git worktree add -q ../RecipeKit-local -b local
$ cd ../RecipeKit-local
$ xcodebuild -scheme RecipeKit -destination 'generic/platform=macOS' -derivedDataPath .build/DerivedData -quiet build
$ du -sh .build/DerivedData
19M .build/DerivedData
$ cd ../RecipeKit
$ git worktree remove --force ../RecipeKit-local The example uses --force because .build is untracked there, and git worktree remove refuses to remove a worktree with untracked files. Add .build/ to .gitignore instead: ignored files do not count, so the worktree and its build data go without --force, and uncommitted work still blocks the removal. In the Xcode app, Settings > Locations > Derived Data can be set to Relative, which keeps each workspace's build data next to it.
Running several agents or branches in parallel? Each worktree's first build is a full build, which is the cost of isolation. Xcode 27 adds a Product > Delete Derived Data menu item, which Apple covered in the WWDC26 Xcode Tips and Tricks group lab. The report above is the targeted alternative when you only want to remove what deleted worktrees left behind.
FAQ
Does git worktree remove delete Xcode DerivedData?
No. Xcode stores build data in ~/Library/Developer/Xcode/DerivedData, outside the worktree, so removing the worktree leaves its folder there until you delete it.
How do I know which DerivedData folder belongs to which worktree?
Each folder's info.plist records the project path as WorkspacePath. Print it with plutil -p or PlistBuddy, or run derived-data-report.sh, which also shows the worktree branch and whether the path still exists.
Can I delete DerivedData folders by project name?
No. Every worktree of a repository gets a folder that starts with the same project name, so a wildcard deletes the build data of worktrees you still use. Match on the recorded path instead.
Why is there a DerivedData folder without info.plist?
Xcode keeps shared caches at the top of DerivedData, such as SDKExplicitPrecompiledModules and ModuleCache.noindex, which belong to no project. In our test a worktree build also left a companion folder named after the worktree directory, sharing the hash of the recorded folder.
How do I stop worktrees from filling DerivedData?
Build with xcodebuild -derivedDataPath .build/DerivedData inside the worktree and add .build/ to .gitignore, or set Derived Data to Relative in Xcode Settings > Locations. git worktree remove ignores Git-ignored files, so the build data is removed with the worktree.
Quote this guide
You can quote sections, tables, commands and charts from this guide in your own posts, documentation or answers, as long as you link to this page. Please do not republish the whole guide. The scripts are MIT licensed, so you can copy and change them freely.
AppScreens.com, "Clean Up Xcode Build Data Left Behind by Git Worktrees", 3 November 2026. https://appscreens.com/blog/xcode-derived-data-git-worktreesSources
- Git: git-worktree documentation
- Worktrunk: tips and patterns (Xcode DerivedData cleanup hook)
- Apple: WWDC26 Xcode Tips and Tricks group lab
Tested on macOS 26.7.2 with Xcode 27.2 (27B52). Apple does not document DerivedData's folder naming or info.plist, and either can change between Xcode versions.





