T tomlkit·org
Inspect Formatter Validator Lint Stats Keys Query Set value pyproject Cargo Convert TOMLJSON JSONTOML TOMLYAML YAMLTOML INITOML TOMLINI .envTOML TOML.env TOMLTS TOMLCSV CSVTOML TOMLXML TOML.properties .propertiesTOML Transform Sort keys Flatten Unflatten Redact Minify Generate Go struct Rust struct Python types JSON Schema Compare Diff Merge Lockfile diff requirements.txtpyproject

requirements.txt to pyproject.toml

updated 7 September 2026

Most of a requirements.txt is already PEP 508 and moves across unchanged. The work is in the rest: the pip flags that have no home in project metadata, the #egg= spellings that predate direct references, the same package pinned twice in a file nobody has read end to end since 2021. This does the move and tells you about all three.

PEP 508 · PEP 621 · PEP 735

What this tool does

It reads a pip requirements.txt and writes the PEP 621 equivalent: a dependencies array inside [project], an extra under [project.optional-dependencies], or a PEP 735 [dependency-groups] entry — with a [build-system] table on top when you want a whole file rather than a fragment.

The intent it closes: "we are finally packaging this properly and there are four requirements files." Convert each one, paste the arrays into the same pyproject.toml, and the runtime set becomes dependencies while dev, docs and test become groups.

When you'd reach for it

  • Moving a project onto uv, Poetry, Hatch or PDM, all of which read pyproject.toml and none of which read requirements.txt as metadata.
  • Turning an application into an installable package, where the dependency list has to live in the distribution rather than beside it.
  • Splitting one grab-bag file into runtime versus dev without hand-editing TOML arrays.
  • Auditing what is really in there: the duplicate pins and dead flags surface as soon as the file is read by something other than pip.

What moves, and what cannot

Straight across. Version specifiers (>=, ~=, ==, !=, and comma-separated combinations), extras (requests[security]), environment markers (; python_version < "3.10") and PEP 508 direct references (widget @ git+https://…) are all valid in a dependencies array exactly as pip writes them. Every line is checked against the PEP 508 grammar on the way through, so a typo becomes a message rather than a broken pyproject.toml.

Rewritten. pip's old git+https://host/repo.git#egg=widget becomes widget @ git+https://host/repo.git, which is the standard spelling. --hash=sha256:… is stripped: hashes pin an artefact, and artefacts are a lock file's business, not project metadata's.

Reported, not converted. These have no PEP 621 equivalent, and inventing one would be worse than saying so:

  • -r other.txt — an include. Convert that file too and merge the arrays.
  • -c constraints.txt — constraints stay a resolver input; uv and pip-tools keep them in their own file.
  • -e . / --editable — an editable install is how you set up a checkout, not something the package depends on.
  • --index-url, --extra-index-url, --find-links, --trusted-host — index configuration. It belongs in pip.conf, in [[tool.uv.index]], or in CI.

Each one is listed as a comment under the output with the reason, so nothing disappears silently.

Duplicates. A package named twice is folded to one entry. If the two lines are identical it is counted and forgotten; if they disagreedjango>=4.1 and Django>=4.2,<5.0 — the first is kept and the conflict is named in full, because the two lines mean different things and only you know which one is current. Names are compared the way PyPI compares them (PEP 503: case-insensitive, with -, _ and . equivalent), so Django and django are one package.

Options reference

  • Write into[project] dependencies emits a complete minimal file with a build backend. optional-dependencies emits the extra so users can pip install yourpkg[dev]. dependency-groups emits PEP 735, the modern place for dev-only tooling that must never be installed with the package. just the array emits the bare TOML array to paste into a file you already have.
  • Group — the name of the extra or group. Anything that is not a bare TOML key is quoted for you.
  • Project name / requires-python / Backend — the fields the [project] table cannot legally omit. version is written as 0.1.0; change it or make it dynamic once the file is yours.
  • Order — as written, or alphabetically by normalised name. Sorting makes future diffs readable; keeping the order preserves whatever grouping the comments imply.
  • Names — leave the spelling alone, or normalise to the PEP 503 form. Normalising makes duplicate detection obvious to a human reader too, but installers accept either.
  • Comments — keep, and each trailing # note rides along on its dependency's line; or drop for a clean array.

Example

In:

requests[security]==2.31.0  # pinned until the proxy is fixed
importlib-metadata>=6.0; python_version < "3.10"
git+https://github.com/example/[email protected]#egg=widget
--index-url https://pypi.example.com/simple

Out, with just the array:

[
  "requests[security]==2.31.0",  # pinned until the proxy is fixed
  "importlib-metadata>=6.0; python_version < \"3.10\"",
  "widget @ git+https://github.com/example/[email protected]",
]

# ---- from requirements.txt, not carried across --------------------------
# line 4: --index-url https://pypi.example.com/simple
#   an index location - it belongs in pip.conf, uv's [[tool.uv.index]], or your CI config

The marker survives with its quotes escaped, the #egg= reference is rewritten, and the flag is accounted for rather than dropped.

Limits

  • It does not resolve anything. No index is contacted, no version is looked up, nothing is installed. The output is your file rewritten, not a resolution of it.
  • An included file is not fetched. -r base.txt cannot be followed from a browser tab; convert that file separately.
  • It writes a minimal [project]. Description, readme, authors, classifiers and URLs are yours to add — run the result through the pyproject.toml validator and it will list what a real distribution still wants.
  • Poetry's own dependency syntax is a different shape. This writes PEP 621, which modern Poetry reads. It does not emit the legacy [tool.poetry.dependencies] caret table.

FAQ

Is my requirements file uploaded? No. It is parsed and converted in your browser; nothing leaves the page. Internal package names and private index URLs are a normal part of these files, which is exactly why this one runs locally.

What about a pip-compile output with hashes? Paste it in — the hashes are stripped and reported. But a compiled file is a lock, not a dependency declaration: the version pins in it are resolved output. Convert the .in file instead, and keep the compiled one as your lock.

Can I put dev tools in a group and runtime deps in the project table? Yes, in two passes: convert requirements.txt with [project] dependencies, then convert requirements-dev.txt with [dependency-groups] and paste the second block under the first.

Why is my -e . line missing? It is listed in the comment block. An editable install of the project itself is not a dependency of the project; with a pyproject.toml in place, pip install -e . is the command you run rather than a line in a file.

Check the result with the pyproject.toml validator, tidy it with the formatter, and compare two of them with diff. If you are on the Rust side of the same problem, the Cargo.toml validator and the lockfile diff do the equivalent jobs.