How to migrate from pip-tools to uv

At Caktus, many of our projects use pip-tools for dependency management. Following Tobias’ post How to Migrate your Python & Django Projects to uv, we were looking to migrate other projects to uv, but the path seemed less clear with existing pip-tools setups. Our requirements are often spread across multiple files, like this:

$ find requirements/ -type f
requirements/test/test.in
requirements/test/test.txt
requirements/deploy/deploy.txt
requirements/deploy/deploy.in
requirements/dev/dev.txt
requirements/dev/dev.in
requirements/base/base.in
requirements/base/base.txt

In a perfect world, we would have always pinned the versions directly in our .in files. But we’re not perfect; sometimes those versions drift, or we forget to update them. Simply importing these with uv add -r does not work well. It either misses pinned versions from our .in files or pins all the sub-dependencies in the .txt files, which we want to avoid.

Our ideal solution is in the middle: updating .in files with the specific versions pinned in the corresponding .txt files.

Create a pinning script

We can automate this with a simple Python script. This script reads the versions from a .txt file and applies them to the packages listed in the associated .in file.

# pin_dot_in_requirements.py
import click
from pathlib import Path


@click.command()
@click.argument("infile", type=click.Path(exists=True, dir_okay=False, path_type=Path))
def main(infile: Path):
    """
    Pins versions in a pip-tools .in file based on its corresponding .txt file.
    """
    txt_file = infile.with_suffix(".txt")
    if not txt_file.exists():
        raise click.FileError(str(txt_file), hint="Corresponding .txt file not found")

    # Build package version mapping from the .txt file
    pkg_version: dict[str, str] = {}
    for line in txt_file.read_text().splitlines():
        # A simple check for a pinned version line
        if "==" in line and not line.strip().startswith("#"):
            pkg, version = line.split("==", 1)
            # Normalize package names to lowercase for matching
            pkg_version[pkg.strip().lower()] = version.strip().split(" ", 1)[0]
    click.echo(f"Parsed {len(pkg_version)} packages from {txt_file}")

    # Write the new, pinned .in file
    outfile = infile.with_suffix(".with_versions.in")
    with outfile.open("w") as out:
        # Loop through lines in the original .in file
        for line in infile.read_text().splitlines():
            pkg = line.strip()
            # Ignore comments, blank lines, flags, or already-pinned packages
            if not pkg or pkg.startswith(("#", "-c", "-e")) or any(i in pkg for i in "=@><"):
                out.write(f"{line}\n")
                continue

            full_pkg = pkg
            # If extras are specified in the package name (e.g. celery[redis]),
            # remove them, since the packages in the .txt don't include extras
            if "[" in pkg:
                pkg = pkg.split("[")[0].strip()

            # Find the version, trying to match different naming conventions
            # (e.g., django_extensions vs django-extensions)
            version = pkg_version.get(pkg.lower())
            if not version:
                version = pkg_version.get(pkg.replace("_", "-").lower())

            if version:
                out.write(f"{full_pkg}=={version}\n")
            else:
                click.echo(f"Warning: No version found for package '{full_pkg}'")
                out.write(f"{full_pkg}\n")

    click.echo(f"Done. Output written to {outfile}")


if __name__ == "__main__":
    main()

We prefer this approach because:

  1. We don’t end up polluting our new pyproject.toml file with a needlessly detailed list of requirements, i.e., by importing the .txt file directly.
  2. We capture the currently pinned version of the requirements we actually care about, to avoid accidentally upgrading a package when we’re only attempting to migrate to uv.

It’s important to note that this method only pins your project’s direct dependencies. When uv resolves the environment, it may select newer versions of sub-dependencies than what were specified in your original requirements.txt files.

For our purposes, we’re comfortable with this trade-off. It allows us to benefit from security patches and bug fixes in underlying libraries, and we rely on our comprehensive unit tests and QA processes to catch any regressions that might arise from these updates.

Pin the requirements

Run the script on your .in files to generate new files with the versions pinned.

python pin_dot_in_requirements.py requirements/base/base.in
python pin_dot_in_requirements.py requirements/dev/dev.in
python pin_dot_in_requirements.py requirements/test/test.in
python pin_dot_in_requirements.py requirements/deploy/deploy.in

Add to uv

Finally, add the newly generated, version-pinned files to your pyproject.toml using uv.

uv add -r requirements/base/base.with_versions.in
uv add -r requirements/dev/dev.with_versions.in --group dev
uv add -r requirements/test/test.with_versions.in --group dev
uv add -r requirements/deploy/deploy.with_versions.in --group deploy

And that’s it! With a quick script, you can cleanly migrate your pip-tools managed dependencies to uv, preserving your pinned versions without importing unnecessary sub-dependencies.