Skip to content

How to migrate a Django project from pip-tools to uv

Django projects that use pip-tools rarely keep a single requirements file. They keep a requirements/ directory where base.in holds the runtime packages and dev.in and test.in each open with an include line such as -r base.txt. That include chain is the one piece a generic requirements.txt migration does not address, and importing it verbatim puts Django itself in the dev group.

This guide maps the layers onto uv dependency groups so the structure survives the move.

Prerequisites

  • A Django project with a requirements/ directory compiled by pip-compile
  • uv installed on your machine

Steps

1. Read the include chain

Open each .in file and note which layer it pulls in:

$ head -1 requirements/*.in
==> requirements/base.in <==
django>=5.0,<5.2

==> requirements/dev.in <==
-r base.txt

==> requirements/test.in <==
-r base.txt

Each layer maps to one destination:

pip-tools file uv destination
requirements/base.in [project.dependencies]
requirements/dev.in [dependency-groups] dev
requirements/test.in [dependency-groups] test
requirements/*.txt uv.lock

Import the .in files, never the .txt files. A compiled base.txt lists every transitive dependencyA package your dependency depends on. When you install requests, its own dependencies (urllib3, certifi, etc.) are transitive dependencies. at an exact version, so importing it writes packages into pyproject.toml that Django never asked for directly.

2. Create pyproject.toml beside manage.py

$ uv init --bare
Initialized project `mysite`

--bare writes a pyproject.toml with no sample code, no README, and no [build-system] table, which is what a Django site wants. Your manage.py and settings package stay where they are.

3. Pin the interpreter pip-compile used

uv init picks the newest interpreter it can find, which is often not the version your requirements/*.txt files were compiled against. Pin it explicitly:

$ uv python pin 3.13
Pinned `.python-version` to `3.13`

Skipping this step resolves the whole project against a different Python than production runs, which is how psycopg or asgiref end up on versions the compiled files never contained.

4. Import the base layer

$ uv add -r requirements/base.in
Using CPython 3.13.14
Creating virtual environment at: .venv
Resolved 8 packages in 132ms
Installed 6 packages in 182ms
 + asgiref==3.12.1
 + django==5.1.15
 + django-environ==0.14.0
 + psycopg==3.3.4
 + psycopg-binary==3.3.4
 + sqlparse==0.6.0

uv preserves the constraints base.in declares and adds a lower bound for the rest:

pyproject.toml
[project]
dependencies = [
    "django>=5.0,<5.2",
    "django-environ>=0.14.0",
    "psycopg[binary]>=3.3.4",
]

Exact versions go to the lockfileA file that records the exact version of every installed package, so everyone working on the project gets identical installs. (uv.lock) instead, so pyproject.toml keeps declaring intent while the lockfile handles reproducibility.

5. Import each layer without its include line

Warning

Do not run uv add --group dev -r requirements/dev.in. uv follows the -r base.txt include and flattens the entire base layer into the dev group as exact pins:

dev = [
    "asgiref==3.12.1",
    "django==5.1.15",
    "django-debug-toolbar>=6.3.0",
    "django-environ==0.14.0",
    "psycopg==3.3.4",
    "psycopg-binary==3.3.4",
    "ruff>=0.16.5",
    "sqlparse==0.6.0",
]

Only django-debug-toolbar and ruff belong there. The rest are runtime packages, now pinned twice and pinned harder than base.in ever asked.

Pass only the lines each layer declares for itself:

$ uv add --group dev $(grep -v '^-r ' requirements/dev.in)
$ uv add --group test $(grep -v '^-r ' requirements/test.in)

Filtering out the -r line hands uv only the remaining package names. Verify the result before moving on:

pyproject.toml
[dependency-groups]
dev = [
    "django-debug-toolbar>=6.3.0",
    "ruff>=0.16.5",
]
test = [
    "pytest>=9.1.1",
    "pytest-django>=4.14.0",
]

6. Rebuild the layering with include-group

Dependency groups compose through the include-group key, defined by PEP 735, the standard for [dependency-groups] in pyproject.toml. If dev.in pulled in test.in under pip-tools, say so:

pyproject.toml
[dependency-groups]
dev = [
    {include-group = "test"},
    "django-debug-toolbar>=6.3.0",
    "ruff>=0.16.5",
]

No group needs to include the base layer. [project.dependencies] installs alongside every group, which is what the -r base.txt line was doing by hand.

uv sync installs the dev group by default, so one command now covers what pip-sync requirements/dev.txt did:

$ uv sync
Resolved 17 packages in 16ms

7. Run management commands through uv

uv run executes the command inside the project’s virtual environment, ensuring all dependencies are installed first. No activation step, and no stale environment:

$ uv run python manage.py check
System check identified no issues (0 silenced).

Apply the same prefix to migrate, runserver, and createsuperuser. Update the project README and any Makefile targets in the same commit so nobody keeps calling the old venv.

8. Retire pip-compile

Confirm production installs cleanly with groups excluded, which is the new pip-sync requirements/base.txt:

$ uv sync --no-default-groups
Resolved 17 packages in 2ms
Uninstalled 8 packages in 21ms
 - django-debug-toolbar==6.3.0
 - iniconfig==2.3.0
 - packaging==26.3
 - pluggy==1.6.0
 - pygments==2.21.0
 - pytest==9.1.1
 - pytest-django==4.14.0
 - ruff==0.16.5

Then delete requirements/ and the pip-compile invocations that wrote files into it:

$ rm -r requirements/

If a deployment target still reads a flat file, regenerate one from the lockfile instead of hand-maintaining it:

$ uv export --no-default-groups --format requirements-txt -o requirements.txt

The export carries hashes without extra flags and marks platform-specific packages with environment markers such as psycopg-binary==3.3.4 ; implementation_name != 'pypy'. One file covers every platform, where pip-compile output is tied to the interpreter and platform that produced it.

Add a drift check to CI so the lockfile cannot fall behind pyproject.toml:

$ uv lock --check

Frequently asked questions

Should I import requirements/base.txt or requirements/base.in?

Import the .in file. base.txt is pip-compile output holding every transitive dependency at an exact version, so importing it writes packages into pyproject.toml that the project never asked for directly. The .in file lists what Django actually depends on, and uv.lock captures the resolved transitive set.

Why did importing requirements/dev.in add Django to my dev group?

Django’s layered requirements files open with an include line such as -r base.txt, and uv add -r follows it. uv imports every package from the base layer into the dev group as an exact pin. Strip the include line before importing, then compose the layers with include-group.

How do I install only production dependencies on the server?

Run uv sync --no-default-groups. That installs [project.dependencies] and skips every group, matching the old pip-sync requirements/base.txt behavior. A plain uv sync gives developers the full toolchain because the dev group installs by default.

Can I keep a requirements.txt for my deployment tooling?

Yes. Run uv export --no-default-groups --format requirements-txt -o requirements.txt to regenerate a pinned, hashed file from uv.lock. Treat it as a build artifact that uv rewrites, not a file anyone edits.

Does uv replace pip-compile and pip-sync directly?

uv ships uv pip compile and uv pip sync as faster drop-in replacements for projects that need to keep their requirements files. Migrating to pyproject.toml and uv.lock goes further, replacing the per-layer, per-platform files that pip-compile generates with one universal lockfile.

Pick your next step

Learn More

This handbook is free, independent, and ad-free. If it saved you time, consider sponsoring it on GitHub.

Last updated on