How to Find and Delete Old node_modules, .build and .venv Folders on a Mac
The build folders in projects you’ve stopped working on held about 27 of the 63 GiB on the test Mac, and those are the ones safe to clear.

On this page
TL;DR
Delete build folders only in projects you haven’t touched in a while. Treat a project as idle when its last commit is 90 or more days old and git status is empty. That’s a choice, not a standard. For Swift, run swift package reset in the project. It removes .build entirely, while swift package clean leaves a 2 MiB skeleton that keeps the downloaded dependencies. For Node, the npm documentation lists no command that removes node_modules, so delete the folder and reinstall when you need it back: npm ci if the project has a package-lock.json, otherwise the package manager whose lockfile it has. The Python docs call a .venv disposable, so delete it and recreate it. First check the project has a requirements file or a lockfile. Expect to pay a rebuild the next time you open a project you cleared. Rebuild time wasn’t measured.
$ diskutil info / | grep "Container Free Space"
$ git -C "<project-folder>" status --porcelain
$ swift package reset --package-path "<project-folder>"
$ rm -rf "<project-folder>/node_modules"
$ rm -rf "<project-folder>/.venv"Run the rm and reset lines only if git status --porcelain printed nothing and the last commit is 90 or more days old. git status doesn’t report what’s inside a folder that .gitignore lists, and projects usually list these, so that check can’t see a hand-patched package inside one. Each command deletes the folder for good.
Tested with
- macOS
- 27.0.1
- Swift
- 6.4
- Date
- October 2026
Part 1 of this series, finding what’s filling a developer Mac,
turned up one of the biggest causes on mine: the build folders that old
projects leave behind. Across my projects folder, 110 .build folders held
52.7 GiB of the 63.2 GiB that the three kinds below added up to. Each cause in
the series gets its own article, and
this one uses the build tool’s own command where one exists. You don’t need
part 1 to follow the steps here.
Find the build folders in your projects folder
These commands only read. -prune stops find from descending into a match,
so a node_modules nested inside another isn’t counted twice. Replace
<projects-folder> with the folder that holds your projects, such as
~/Projects.
Count the folders of each kind:
$ find <projects-folder> -type d -name .build -prune -print | wc -l
$ find <projects-folder> -type d -name node_modules -prune -print | wc -l
$ find <projects-folder> -type d -name .venv -prune -print | wc -lAdd up the size of one kind, in GiB. du -sk prints sizes in KiB, and awk
sums them:
$ find <projects-folder> -type d -name .build -prune -exec du -sk {} + | awk '{ kib += $1 } END { printf "%.1f GiB\n", kib / 1024 / 1024 }'Run it again with node_modules and .venv in place of .build.
Compare your totals with one machine’s
The totals here come from one Mac. The idle column used the last-commit test
only: a project counted as idle when its last commit was 90 or more days old,
and a folder that isn’t a git repository used the folder’s modified time
instead. It didn’t check git status. The loop in the next section applies
both tests and skips folders that aren’t git repositories, so its totals can
come out lower than this column.
| Kind | Directories | GiB | GiB in idle projects |
|---|---|---|---|
.build | 110 | 52.7 | 24.5 |
node_modules | 20 | 7.4 | 1.5 |
.venv | 41 | 3.1 | 1.1 |
| Total | 171 | 63.2 | 27.1 |
The largest single .build was 8.6 GiB, in a project idle for 156 days.
du -sk is an upper bound on what deleting frees. APFS clones share their
blocks with the original until one of them changes, and du counts a cloned
file at full size wherever it appears.
Pick the idle projects
A build folder in a project you’re working in costs you a rebuild. Delete only where both of these hold:
- The last commit is 90 or more days old.
- Running
git status --porcelainprints nothing, so the source tree has no uncommitted change.
git status doesn’t report what’s inside a folder that .gitignore lists,
and build folders usually are listed, so the second test says nothing about
what’s inside one. A package you patched by hand in node_modules, or
installed into a .venv without adding it to a requirements file, is lost
with the folder. If you might have done either, check before you delete.
Use a different number than 90 if your projects move at a different pace. This
loop applies both tests and prints a count and a total per kind. It skips a
folder whose parent isn’t a git repository, because the loop has no commit to
read there. A project where git itself fails, such as one with a damaged
index or no commits yet, is counted as skipped rather than clean. Replace
~/Projects with your projects folder:
projects=~/Projects
for kind in .build node_modules .venv; do
count=0; kib=0; skipped=0
while IFS= read -r dir; do
project=$(dirname "$dir")
git -C "$project" rev-parse --is-inside-work-tree >/dev/null 2>&1 || { [ -e "$project/.git" ] && skipped=$((skipped + 1)); continue; }
recent=$(git -C "$project" log -1 --since='90 days ago' 2>/dev/null) || { skipped=$((skipped + 1)); continue; }
[ -z "$recent" ] || continue
changes=$(git -C "$project" status --porcelain 2>/dev/null) || { skipped=$((skipped + 1)); continue; }
[ -z "$changes" ] || continue
count=$((count + 1))
kib=$((kib + $(du -sk "$dir" | cut -f1)))
done < <(find "$projects" -type d -name "$kind" -prune -print 2>/dev/null)
echo "$kind: $count idle and clean, $((kib / 1024 / 1024)) GiB, $skipped skipped because git failed"
doneThe GiB figure is rounded down, so a kind under 1 GiB prints 0. A project that isn’t a git repository needs your own judgment. The folder’s modified time is a rough substitute, and it’s the one the table used.
To list the idle projects, add echo "$project" on the line after
count=$((count + 1)).
Delete a .build folder with swift package reset
SwiftPM has two commands that sound alike. The documentation describes
swift package clean (opens in a new tab)
as “Delete build artifacts.” and
swift package reset (opens in a new tab)
as “Reset the complete cache/build directory.” Neither page says what
survives, so I ran each on a throwaway copy of one idle project. Both copies
had a .build of about 678 MiB:
| Command | .build afterward |
|---|---|
swift package clean | About 2 MiB, still holding checkouts, repositories, artifacts and workspace-state.json |
swift package reset | Removed entirely |
clean removes the compiled products and keeps the downloaded dependencies.
reset removes the folder, so the dependencies go with it. To get the space
back, use reset.
swift package reset deletes the .build folder, including downloaded
dependencies, in the package that --package-path names. Point it at the
folder that holds the .build and its Package.swift, after the
git status --porcelain check:
$ git -C "<project-folder>" status --porcelain
$ swift package reset --package-path "<project-folder>"On the real project, whose .build measured about 675 MiB,
git status --porcelain printed nothing before or after, .build was gone,
and Container Free Space rose by about 675 MiB.
The next build of that project has to rebuild what reset removed, and how
long that takes wasn’t measured. If a project keeps its build output somewhere
other than .build, both commands take --scratch-path, whose default is
.build.
Delete node_modules in an idle project
The npm documentation lists no command that removes node_modules. It describes
npm prune (opens in a new tab) as removing
only extraneous packages. npm ci (opens in a new tab)
deletes the folder as a side effect: “If a node_modules is already present,
it will be automatically removed before npm ci begins its install.” That
reinstalls rather than frees space.
This deletes the folder and everything in it. package.json and the lockfile
stay, so the project’s package manager restores it: npm ci needs a
package-lock.json or npm-shrinkwrap.json, and a project with a yarn.lock
or pnpm-lock.yaml reinstalls with that tool instead. Replace
<project-folder> with the idle project’s root, and run it after the
git status --porcelain check:
$ rm -rf "<project-folder>/node_modules"Delete a .venv in an idle project
The Python documentation calls a virtual environment
“Considered as disposable – it should be simple to delete and recreate it from scratch.” (opens in a new tab)
No command removes one. --clear empties the directory and recreates the
environment in place, so it doesn’t clear space for good.
Look for a requirements file or a lockfile in the project first. If there’s
none, stop: the environment is the only place the installed package list might
live, and recreating it needs that list. To recreate the environment later, run
python3 -m venv .venv in the project and install from the file. A .venv
removal wasn’t measured.
The next command deletes the folder and everything in it. Replace
<project-folder> with the idle project’s root, and run it after the
git status --porcelain check:
$ rm -rf "<project-folder>/.venv"Confirm the space came back
Read the container’s free space before the first deletion and again after the last:
$ diskutil info / | grep "Container Free Space"Wait at least 20 seconds after the last deletion before the second reading. In
the test, readings 20, 40 and 60 seconds after swift package reset agreed
within about 5 MB.
Do this with a coding agent
Paste this prompt into your own local coding agent. It reports first and removes nothing until you confirm:
Find build-output directories under <projects-folder>: .build, node_modules and
.venv. Measure and report first; delete nothing until I confirm.
1. Record free space with diskutil info / (Container Free Space).
2. Find every .build, node_modules and .venv directory under the folder with
find -prune, so nested matches aren't counted twice. For each project that
holds one, report the directory's size (du -sk), the date of the last
commit, and whether git status --porcelain is empty. Say plainly that du
counts APFS clones at full size, so a total is an upper bound.
3. List the projects whose last commit is 90 or more days old and whose git
status is clean as candidates. Treat a project that is not a git repository
as needing my decision, and say so.
4. Stop and wait. I will say which candidates to clear.
5. For each one I confirm, use the ecosystem's own command where it has one:
swift package reset for .build. There is no npm or Python command that
removes node_modules or .venv, so for those, delete only the directory I
confirmed, by its full path. Check a requirements file or lockfile exists
before removing a .venv, and tell me which lockfile each node_modules
project has so I know how to reinstall it. git status does not look inside
ignored folders, so ask me whether I patched or hand-installed anything in
one before deleting it.
6. Record free space again with the same command, after at least 20 seconds,
and report the difference. Report it against the du total, and don't claim
a rebuild time you did not measure.