Let me ask you something, and you can be honest.
When was the last time you created a Python virtual environment and then actually deleted it? Not deactivated it. Not closed the terminal and told yourself it was somebody else’s problem. Deleted it. I have shipped Python work for years and my honest answer is somewhere around one out of every forty.
We create environments like they are free, because as far as the tutorial we were following is concerned, they are. python -m venv .venv costs nothing and says nothing. It does not flash a warning at you. It does not add itself to a list somewhere. It just quietly sits there, holding anywhere from 30 MB of a bare stdlib install to well over 2 GB once you have pip-installed torch, transformers, and the four conflicting CUDA wheel variants that come with them.
Then one day your drive is full. You open the project folder, and there it is. Then you check the folder next to it, and there is another one. And another. A graveyard of environments for tutorials you finished, experiments that went nowhere, and side projects you have not touched in eleven months. Each one a fully stocked Python installation that has done nothing but take up space since the day you created it.
I finally got annoyed enough to do something about it. The result is KillVenv, a cross-platform terminal utility that hunts down every abandoned Python virtual environment on your machine, shows you exactly how much space each one is hoarding, and then gets out of your way so you can decide what dies.
So what did I end up building?
Two scripts. One PowerShell, one Bash. No installer, no pip package, no binary to trust, no Node runtime to download. You paste one line into a terminal and it scans every local volume attached to your machine.
Here is the entire installation process on macOS or Linux:
bash <(curl -fsSL https://raw.githubusercontent.com/zpratikpathak/KillVenv/home/venv-clean.sh)And on Windows:
$url="https://raw.githubusercontent.com/zpratikpathak/KillVenv/home/venv-clean.ps1"; $file="$env:TEMP\venv-clean.ps1"; Invoke-WebRequest $url -OutFile $file; & $fileWindows will ask for administrator elevation, and that is not a landgrab. Scanning a whole C: drive means reading directories you cannot otherwise see, and the script would rather ask once than silently miss half your environments.
Accept it, and this is what you get:

That is the whole product. No settings menu, no checkboxes hidden behind three tabs, no upgrade prompt hiding behind a paywall. It scans, it lists, you pick, it deletes.
It runs cross-platform from the same repository, which mattered more to me than it probably should. Windows gets venv-clean.ps1, macOS and Linux get venv-clean.sh. Two implementations of the same idea, because the one thing I refused to do was ship a tool that only worked on one of the machines I actually own.
Why the word venv sells it short
Calling this a venv cleaner is misleading, and the name is entirely my fault. Nobody who has been doing Python for a while is still using plain venv and nothing else. The second you adopt a real tool, it starts putting environments somewhere else entirely.
Poetry does not put its environments next to your pyproject.toml by default. It puts them in a central cache: ~/.cache/pypoetry/virtualenvs on Linux, ~/Library/Caches/pypoetry/virtualenvs on macOS. You will never stumble across those by cd-ing around your own projects, because they are not in your projects. They are in a cache directory you have probably never opened.
So if you go looking for abandoned environments with a folder-name search, you will find a few, feel virtuous about it, and miss the biggest pile entirely. KillVenv understands eight different providers, and more importantly it knows where each one hides its leftovers:
- venv / virtualenv – the classics. Sit in your project folder as
.venv,venv,env, or whatever the tutorial told you to type. - Poetry – central cache, far from your project, plus a
poetry.locksitting next to it as a hint. - uv – announces itself with a
uv =line insidepyvenv.cfg, or auv.lockin the parent directory. uv is fast, which means you create environments even more carelessly than before. - Pipenv –
~/.local/share/virtualenvs, keyed by a hash of your project path, so the folder name tells you nothing useful. - Conda – identified by
conda-meta/historyrather than a config file, and the base install itself is protected. - PDM and hatch – newer, quieter, and showing up in more
pyproject.tomlfiles every month. - pipx – isolated app environments that are NOT yours to delete, which is exactly why they get protected instead of listed.
That last bullet is the interesting one, and it took me a minute to get right. pipx environments look exactly like regular virtual environments from the outside, and deleting one does not free space for you, it uninstalls a command-line tool you actually use. Same for uv tool environments. So KillVenv does not just find environments, it knows which ones belong to somebody else.
Which brings me to the one rule I refused to break.
The rule I refused to break: a name is not evidence
Here is the lazy way to write this tool: walk the filesystem, look for folders named .venv or env or venv, delete them. That is also how you write a tool that eventually deletes something a person actually needed.
env is a spectacularly common folder name. So is .env, which plenty of projects use to hold secrets, not Python. virtualenv is a word that appears in documentation, in shell history, and in the names of folders people made by hand for reasons of their own. Any tool that treats a name as proof is one typo away from ruining somebody’s afternoon.
So KillVenv requires structural evidence before a directory is even shown to you. A modern environment has to present pyvenv.cfg plus at least one corroborating marker:
verify_environment() {
local p="$1" markers=0 evidence=''
[ -d "$p" ] || return 1
if [ -f "$p/pyvenv.cfg" ]; then
evidence='pyvenv.cfg'
if has_python_executable "$p"; then markers=$((markers+1)); evidence="$evidence, bin/python"; fi
if [ -f "$p/bin/activate" ]; then markers=$((markers+1)); evidence="$evidence, bin/activate"; fi
if has_site_packages "$p"; then markers=$((markers+1)); evidence="$evidence, site-packages"; fi
# pyvenv.cfg alone is NOT enough. Need at least one corroboration.
[ "$markers" -ge 1 ] || return 1
VERIFY_EVIDENCE="$evidence"
return 0
fi
return 1
}Read that condition again. A pyvenv.cfg file on its own is not enough. It needs a Python interpreter, an activation script, or a site-packages directory standing next to it as a second witness. I have seen repositories ship a sample pyvenv.cfg in their docs folder. Under the lazy rule, that repository would be a target.
Older virtualenvs predate pyvenv.cfg entirely, which is a genuinely awkward fact about the Python ecosystem. For those, KillVenv demands all three layout markers at once:
# Legacy/custom virtualenv without pyvenv.cfg: require three independent markers.
if has_python_executable "$p" && [ -f "$p/bin/activate" ] && has_site_packages "$p"; then
VERIFY_EVIDENCE='legacy layout: bin/python + bin/activate + site-packages'
return 0
fiThree independent pieces of evidence, all at once, none of them a name. A folder that happens to be called env and contains a stray config file fails all three checks and is never even listed.
Conda gets its own test, because Conda does not use pyvenv.cfg. It writes conda-meta/history, and a pile of conda-meta/python-*.json package records. Both are checked, and the environment only qualifies if the interpreter or those package records actually exist.
Every environment that survives these checks gets its evidence string shown to you in the interface. Not because it is pretty, but because you should be able to see exactly why the tool believes a folder is what it claims it is before you let it delete the folder.
The five protections, or how I stopped worrying about deleting my own work
A deletion tool that only checks whether something is an environment is half a tool. The other half is checking whether that environment is currently in use, because deleting a live environment is a fantastic way to break your afternoon in a way you cannot immediately explain.
KillVenv protects five categories of environment, and a protected environment cannot even be selected:
- The environment you are standing in. If
VIRTUAL_ENVorCONDA_PREFIXis set, that environment is locked. So is anything underneath your current working directory, and the folder the cleaner script itself lives in. - Running Python processes. Anything with a live interpreter gets locked before you can touch it.
- pipx and uv tool environments. These are installed applications, not project sandboxes, and removing them means uninstalling a tool you use on purpose.
- pyenv installs. Manager-owned, and deleted through
pyenv uninstall, not through brute force. - The Conda base environment, plus anything that looks like a whole anaconda, miniconda, miniforge, or mambaforge distribution.
The running-process check has a subtle bug hiding in it that I am quietly proud of catching. The obvious implementation is to run ps and grep for the environment path. But if you do that while scanning, the ps command itself, and your own grep, are in flight, and you can match your own working paths. So KillVenv snapshots the process table once, before the scan begins:
RUNNING_COMMANDS="$(ps -axo command= 2>/dev/null || true)"
# ... later, during protection checks ...
if printf '%s\n' "$RUNNING_COMMANDS" | grep -F "$p/bin/python" >/dev/null 2>&1; then
VERIFY_PROTECTED=1
VERIFY_REASON='a Python process appears to be running from this environment'
fiThe comment in the source is dry about it, but the ordering matters. Because the snapshot predates the scan, the scanner cannot ever create a false match against itself. It is a one-line fix for a bug that would have been genuinely confusing to debug after the fact.
The deletion step, and why it re-verifies
You select environments with Space, hit Enter, and the screen switches to a review that tells you the total byte count you are about to reclaim. Nothing is deleted yet. You have to type the word DELETE, in full, in capitals. Not press Y. Not hit Enter again. Type it out, because the whole point of a confirmation is that it should be harder than a reflex.
printf '\nAbout %s across %d environment(s) will be permanently removed.\n' "$(human_kb "$kb")" "$count" >&3
printf 'The script will re-verify every directory immediately before deletion.\n' >&3
printf '\nType %sDELETE%s to continue: ' "$C_RED" "$C_RESET" >&3
IFS= read -r answer <&3 || answer=''
if [ "$answer" != 'DELETE' ]; then
STATUS='Deletion cancelled.'; STATUS_COLOR='yellow'; return
fiThen comes the part I care about most, and the reason I trust this tool on my own machine. Between the moment you scanned and the moment you pressed Enter, something could have changed. You could have opened a project, activated an environment, or launched a server. So every single target is re-verified immediately before the rm runs:
recheck_before_delete() {
local p="$1"
verify_environment "$p" || return 1 # still structurally a venv?
protection_for "$p" "$VERIFY_PROVIDER" # still unprotected?
[ "$VERIFY_PROTECTED" -eq 0 ] || return 1
return 0
}If an environment no longer verifies, or has become protected since the scan, it is skipped and the script tells you why. Stale data is the classic way a cleanup tool turns malicious by accident, and re-verification is the answer to it.
Conda environments are removed through conda env remove rather than raw deletion, when conda is available, because Conda keeps its own registry of what exists and a brute-force rm -rf leaves that registry lying about environments that are no longer there.
Scanning a whole drive without taking forever
Scanning an entire filesystem is where naive tools die. A full C: drive has millions of files, and a Python-level directory walk will happily take long enough that you go and make coffee.
KillVenv prunes aggressively. It refuses to descend into common heavy directories that cannot contain an environment, and once an environment is confirmed, it does not recurse into it at all, which is both faster and safer, since package metadata directories inside an environment will happily produce false candidates. On Windows it reaches for raw .NET file checks instead of the PowerShell filesystem provider, because invoking the provider millions of times on a large drive is measurably the slowest thing the script could do.
It also refuses to walk into network filesystems unless you explicitly ask, with --include-network on macOS and Linux or -IncludeNetworkDrives on Windows. Scanning an NFS mount full of other people’s projects is a great way to spend an afternoon and learn nothing about your own disk.
And it stays on one filesystem per root with -xdev, which is what keeps a scan of / from wandering into virtual filesystems like /proc and /sys and treating kernel metadata as candidate environments.
Using the menu
The interface is pure keyboard, because clicking a checkbox in a terminal window felt like a lie. The whole thing:
- Up / Down – move through the list of detected environments
- PageUp / PageDown – scroll a page at a time
- Home / End – jump to the first or last environment
- Space – select or deselect the focused environment
- A – select or deselect every unprotected environment at once
- Enter – review the deletion plan, then confirm
- R – rescan the filesystem
- Q / Ctrl+C – exit cleanly, deleting nothing else
Protected environments appear in the list, because hiding them felt worse than showing them. You can see that pipx is installed and understand why it is not selectable, instead of wondering whether the tool simply failed to notice it. Hitting Space on a protected entry tells you the reason instead of quietly doing nothing.
If you would rather not pipe a script from the internet, which is a completely reasonable position, you can download the ZIP from GitHub, extract it, and run it locally:
git clone https://github.com/zpratikpathak/KillVenv.git
cd KillVenv
chmod +x venv-clean.sh
./venv-clean.shThere is also a --list mode that scans and prints a summary without ever opening the interactive UI, which is what you want if you are running this against a build server or want to pipe the output into a log. And --no-color for when that log is being parsed by something that hates ANSI codes.
What I took away from this
The reason disk cleanup tools feel sketchy is not that deletion is dangerous. It is that most of them delete first and explain never. You hand them permission, a progress bar moves, and afterwards you are left reconstructing what was on your disk from memory. That is a bad design for an operation with no undo.
So KillVenv shows you its work. It shows you the evidence it used to conclude a folder is an environment. It shows you which environments are protected and why. It makes you type a whole word to confirm, and then it re-checks every target in the moment before it acts. Every one of those decisions costs a few lines of code and makes the tool slower to use, and I would not remove any of them.
The other thing, and this is a note to myself more than anyone: this is the second zero-install, single-file tool I have built, and the pattern keeps paying off. One file, no installer, no runtime to download, nothing to trust but readable source. Nobody has to compile anything. Nobody has to wonder what the binary is doing. They read it, they run it, and five minutes later their drive has 40 GB back.
KillVenv is free and open source under the MIT licence. If it found you some space, a star on the repo is the entire price of admission.
Related reading
- Extension Cleaner: Removing Browser Extensions That Refuse to Die – the sibling tool. Same philosophy, aimed at browser extensions that keep reinstalling themselves.
- How to Build a Completely Air-Gapped Python Development Environment in VS Code – for when the environments you do keep have to survive a locked-down network.
- KillVenv on GitHub – the source, both scripts, no dependencies.