The Moment I Knew I Had a Problem
A few months ago, I inherited a Django project at work. Nothing unusual there — it’s part of the job when you manage an ICT division. But when I opened the pyproject.toml to check the dependencies, I nearly choked on my coffee.
Forty-seven dependencies. And I was pretty sure at least a third of them had nothing to do with the actual codebase.
That’s the thing about Python projects. Dependencies pile up over time. Someone tries a library for a feature that never ships. Another developer adds a package “just in case” and forgets to remove it when the approach changes. Before you know it, your pyproject.toml looks like a packed JRPG inventory — half the items are potions you’ll never use, but removing any of them feels like tempting fate.
I needed a way to audit my dependencies. Not manually grep through every import statement — there had to be a tool. That’s when I found pyproject-udeps.

What Is pyproject-udeps?
pyproject-udeps is a CLI tool built by Luke Hsiao that scans your Python project for unused dependencies declared in pyproject.toml. It’s written in Rust — which means it’s fast — and it was inspired by the equally brilliant cargo-udeps from the Rust ecosystem.
Here’s what it supports out of the box:
- Poetry projects — reading from
[tool.poetry.dependencies]and group sections - uv projects — works with
$UV_PROJECT_ENVIRONMENT - Plain PEP 621 projects — those using the standard
[project.dependencies]table - PEP 735 dependency groups — the newer standard for organizing dev dependencies
- Mixed projects — it reads from every place dependencies can be declared, so hybrid setups work fine
Originally released as poetry-udeps, it was renamed to pyproject-udeps to reflect that it works with any modern Python project, not just Poetry-based ones.
How It Works Under the Hood
This is the part I really appreciate. Instead of relying on fragile regex patterns or running your code just to see what breaks, pyproject-udeps uses Ruff’s error-resilient parser to build an AST (Abstract Syntax Tree) of every Python file in your project.
It then collects every import it finds — plain imports, from imports at any nesting level, importlib.import_module() calls with literal arguments, and even __import__() calls. It ignores strings, docstrings, and comments, so you don’t get false matches on someone writing “import os” inside a documentation string.
Those collected imports are matched against your declared dependencies using a built-in name map and a set of naming heuristics. Anything declared but never imported gets flagged.
The clever bit: Python package names don’t always match their import names. For example, you install python-dotenv but import dotenv. The tool’s name map handles these common mismatches. And if your specific package isn’t in the map, you can add it to the src/name_map.rs file and contribute upstream (or just use the ignore file — more on that later).
Getting Started
You’ll need Rust installed on your machine. If you don’t have it yet, install it from rustup.rs — it’s one command and takes a few minutes.
Installation
The most straightforward way:
cargo install pyproject-udeps --locked
Alternatively, if you have cargo-binstall installed, you can download a prebuilt binary directly — faster than compiling from source:
cargo binstall pyproject-udeps
That’s it. The pyproject-udeps command is now globally available.
Running Your First Scan
Navigate to your project root (where pyproject.toml lives) and just run:
pyproject-udeps
On my project, the output was both humbling and satisfying:
httpx
python-decouple
whitenoise
django-debug-toolbar
Four dependencies I was carrying around for absolutely no reason. httpx had been added for an API client that was later replaced with requests. django-debug-toolbar was a dev dependency from an earlier sprint that never got removed. whitenoise and python-decouple were left over from a previous deployment strategy.
I removed all four. The project still worked. My pip freeze output got a little shorter. My deployment images got a tiny bit smaller. It wasn’t a life-changing victory, but it was satisfying — like finally cleaning out that drawer full of random cables.
Dealing With False Positives
The tool’s README is upfront about this: Python dependencies don’t always map 1:1 with import names. You will get false positives, and that’s okay. The goal is to surface candidates for manual review, not to automatically delete packages from your pyproject.toml.
Common cases where a dependency exists but isn’t directly imported in your code:
- Transitive dependencies — packages like
asyncpgthat SQLAlchemy uses internally - Plugins — Django apps that auto-register themselves when installed
- CLI tools — packages you use only from the command line, not imported in code
- Type stubs —
types-requestsand similar packages used only by type checkers
For these, you have two options:
Option 1: The --virtualenv Flag
If a dependency is used by something in your virtualenv (like SQLAlchemy needing asyncpg), pass the --virtualenv flag:
pyproject-udeps --virtualenv
This searches through the site-packages in your virtualenv as well, which catches many of these cases.
Option 2: The Ignore File
For persistent false positives, create a .pyprojectudepsignore file in your project root:
# Packages we know are needed but not directly imported
asyncpg
django-cors-headers
whitenoise
Empty lines and lines starting with # are ignored. The tool supports the legacy filename .poetryudepsignore as a fallback if you’re migrating from the original poetry-udeps.
Checking Dev Dependencies
This is where things get interesting. Dev dependencies are often the most neglected part of a project’s dependency tree. How many times have you seen black, pylint, pytest, and a handful of other tools sitting in [tool.poetry.group.dev.dependencies] without anyone checking if they’re still actively used?
Run pyproject-udeps with the --dev flag:
pyproject-udeps --dev
I tried this on my project and found coverage still listed even though we’d switched to pytest-cov months ago. An easy cleanup.
CI/CD Integration: Automate the Audit
The real power of this tool shines when you add it to your CI/CD pipeline. Pre-built binaries install quickly with taiki-e/install-action:
jobs:
unused-deps:
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- uses: actions/checkout@v5
with:
persist-credentials: false
- uses: taiki-e/install-action@v2
with:
tool: pyproject-udeps
- name: Check for unused dependencies
run: pyproject-udeps
The step fails with exit code 1 when unused dependencies are found, printing them one per line. This is exactly what you want in a CI pipeline — it forces developers to either remove the dependency or add it to the ignore file with a documented reason.
I’ve started adding this to every new Python project at work — it’s a small but meaningful part of keeping the whole software supply chain clean, something I explored in depth in how to lock down your supply chain when AI writes your code. It only adds about a second to the CI run — the Rust binary is that fast — and it catches bloat before it becomes a permanent resident of your pyproject.toml.
How It Compares to Other Options
pyproject-udeps isn’t the only game in town. Here’s how it stacks up against the alternatives:
| Tool | Language | What It Checks | Speed (170k lines) |
|---|---|---|---|
| pyproject-udeps | Rust | Unused deps only | ~110 ms |
| deptry | Python/Rust | Unused, missing, transitive | ~165 ms |
| fawltydeps | Python | Unused + missing | ~3.5 seconds |
| pip-extra-reqs | Python | Unused deps | Failed on large project |
The benchmark numbers are from the project’s own testing on a 170,000-line codebase, measuring only the “unused dependencies” feature. pyproject-udeps and deptry (which has a Rust core in recent versions) are both fast enough that you won’t notice them. For smaller projects — which is most of what I work on — both finish in under a second.
The main differentiator is focus. As I wrote in my piece on how better models don’t always mean better tools, picking the right tool for the job matters more than raw power. pyproject-udeps does one thing: find unused dependencies. It doesn’t check for missing dependencies, transitive dependency issues, or version mismatches. If you want all of those in one tool, deptry is the more comprehensive option.
Personally, I use both. pyproject-udeps for its speed and simplicity, deptry for a deeper audit when I’m doing a proper dependency review.
Should You Use It?
If you maintain any Python project with more than a handful of dependencies, yes. It takes about 30 seconds to install, a second to run, and has zero configuration needed for the basic case. The cost-to-benefit ratio is absurdly good.
I’ve made it a habit to run pyproject-udeps once a month on my active projects — the same way I periodically audit the dependencies of self-hosted tools like my private AI meeting assistant. It’s part of my “spring cleaning” routine — a few minutes to check if the dependency tree is still lean. And every single time, I find at least one package that shouldn’t be there.
Three things I’d recommend you do right now:
- Install it:
cargo install pyproject-udeps --locked - Run it in your main project:
pyproject-udeps - Review the output and clean up anything that shouldn’t be there
That’s it. A cleaner pyproject.toml, smaller deployments, and one less source of technical debt to worry about.