Back to glossary

requirements.txt

requirements.txt is pip's requirements file: a list of Python packages to install, plus pip options that can change where and how they install.

Also known as pip requirements file, requirements file, Python requirements file

requirements.txt, also called a pip requirements file, is the text file that pip reads with pip install -r to install a Python project's dependencies. Each line is a requirement specifier or a pip command-line option, so the file controls versions, package indexes, and hash checks. requirements.txt is a pip convention, not a standard, and it is not secure by default.

The part most guides leave out: a requirements.txt file is a pip command line saved to disk. As of October 2026, pip 26.2.2 accepts 17 global options inside the file, including --index-url, --extra-index-url, --trusted-host, and --no-require-hashes. pip also fetches nested -r files over HTTP. One added line can change where every package in the file comes from, and an exact pin does not close that gap. An == pin names a version, not a file. In our test, acme-billing==1.4.2 installed an impostor 1.4.2 from a second index in three runs out of three.

Our position: treat requirements.txt as generated output, not hand-written configuration. Generate it with hashes, install it with --require-hashes on the command line, keep index settings out of it, and review every diff to it as you would a change to CI configuration. Hashes then guarantee you install the bytes you locked. Hashes cannot tell you whether those bytes were safe to lock.

What is requirements.txt?

#

requirements.txt is a list of things for pip to install, written in pip's own input syntax. The filename is only a convention: pip reads whatever file you pass to -r, and the requirements file format is defined in pip's documentation, not in a packaging standard. Packages usually come from the Python Package Index unless the file or pip's configuration says otherwise. uv and pip-tools read and write the same format, with small differences covered below.

requirements.txt describes an environment, not a project. A library declares the version ranges it supports in pyproject.toml, for example requests>=2.31, so its users can share compatible versions. An application or container image uses requirements.txt to record the exact set of packages it was tested with, including every transitive dependency. A requirements.txt that lists only top-level names without pins records neither. It tells pip to install whatever the index serves on the day you run it.

Python's packaging community is explicit about the format's limits. PEP 751, accepted on March 31, 2025, defines pylock.toml as the standard lock file and makes file hashes mandatory. Its author, Brett Cannon, contrasts that with "requirements files which can optionally include hashes, but it is an opt-in feature and can be bypassed." The next two sections show how.

Every line in requirements.txt is a pip instruction

#

A requirements.txt line is one of four kinds, and only two of them name a package. Figure 1 annotates a file that uses all four.

Figure 1 in text: the four kinds of requirements.txt line, what each looks like, and how far its effect reaches.

Line kindExamplesScopeWhat it can change
Global option--index-url, --extra-index-url, --trusted-host, --find-links, --no-index, --pre, --only-binary, --require-hashes, --no-require-hashesEvery package in the pip runWhere packages come from, whether a host needs valid HTTPS, whether hashes are enforced
Include-r base.txt, -r https://example.com/base.txt, -c constraints.txtEverything in the included fileAnything the included file says, including global options
Requirementurllib3>=2,<3 ; python_version >= "3.10", mylib @ git+https://github.com/example/mylib@3f2c1e0, -e ./libs/sharedOne packageAcceptable versions, or a direct source that bypasses the index
Per-requirement option--hash=sha256:..., --config-settingsOne packageWhich file digests are acceptable, and build backend settings

Three parsing rules complete the format. pip expands POSIX-style ${VAR} references from the environment at install time, and it does not expand $VAR or %VAR%. A trailing backslash continues a line, and # starts a comment. Direct references to Git repositories behave like the Git and URL dependencies other ecosystems have: they skip the index, and a hash cannot cover them.

pip's parser defines exactly which options a file may use. As of pip 26.2.2, req_file.py allows 17: --index-url, --extra-index-url, --no-index, -c, -r, -e, --find-links, --no-binary, --only-binary, --prefer-binary, --require-hashes, --no-require-hashes, --pre, --all-releases, --only-final, --trusted-host, and --use-feature. The index, trust, binary, release, and hash options apply to the whole install, not to the line they sit near.

A remote include can change your index

#

Includes are where the command-line nature of requirements.txt gets dangerous. pip fetches an -r target over HTTP or HTTPS at install time, so part of your dependency list can live on a server you do not control and can change without a commit. We committed this file:

Plain text
--index-url http://localhost:8011/
-r http://localhost:8020/extra.txt
acme-billing

The included file, served from a different local web server, held one line:

Plain text
--extra-index-url http://localhost:8012/

The private index on port 8011 served acme-billing 1.4.2. The second index on port 8012, standing in for a public registry, served acme-billing 9000.0.0. pip 26.2.2 installed the public package:

Plain text
$ python -m pip install -r requirements.txt
Collecting acme-billing (from -r requirements.txt (line 3))
  Downloading acme_billing-9000.0.0-py3-none-any.whl (1.2 kB)
Installing collected packages: acme-billing
Successfully installed acme-billing-9000.0.0

The committed requirements.txt names only the private index. The remote include added a second one, and pip's rule of pooling every index and taking the highest version did the rest. The result is dependency confusion delivered through an include. uv 0.13.6 followed the same include and installed the same 9000.0.0. A reviewer reading the repository would see nothing wrong.

The ${VAR} syntax is how most teams put private index credentials into requirements.txt, as in --extra-index-url https://${PIP_TOKEN}@pkgs.example.com/simple/. Expansion keeps the token out of Git. It also makes requirements.txt the place where package sources are configured, which is the line an attacker most wants to edit. We prefer index settings in pip.conf or PIP_INDEX_URL on the build machine. That does not stop someone who can edit CI configuration. It does mean a pull request that only adds a dependency cannot also change where dependencies come from.

How do you install requirements.txt?

#

You install requirements.txt by running python -m pip install -r requirements.txt inside an activated virtual environment. The python -m form binds pip to the interpreter you name. The virtual environment keeps the packages out of the system Python, which many Linux distributions now refuse to modify.

Bash
python3 -m venv .venv
source .venv/bin/activate        # Windows: .venv\Scripts\activate
python -m pip install -r requirements.txt

uv's equivalent is uv pip install -r requirements.txt. uv reads the same format, but not every option: uv 0.13.6 stopped with a parse error on a --no-require-hashes line in our test.

For CI and container builds, we install a generated, fully hashed requirements.txt with a stricter command. Each flag sits on the command line on purpose, where the file cannot override it:

Bash
python -m pip install \
  --require-hashes \
  --only-binary :all: \
  --no-deps \
  -r requirements.txt

--require-hashes belongs on the command line because requirements.txt can now switch off the implicit form of hash checking. Since pip 26.2, released July 29, 2026, pip's documented rule that one hashed requirement makes every requirement need a hash can be disabled with --no-require-hashes. That option is also legal inside the file. We confirmed it. A file with one hashed and one unhashed requirement failed by default:

Plain text
ERROR: In --require-hashes mode, all requirements must have their versions pinned with ==. These do not:
    acme-tools from http://localhost:8011/acme-tools/acme_tools-2.0.0-py3-none-any.whl#sha256=ec47... (from -r req-mixed.txt (line 4))

Appending --no-require-hashes to the same file made both packages install, the unhashed one unchecked. Passing --require-hashes explicitly made pip refuse the combination instead:

Plain text
$ python -m pip install --require-hashes -r req-mixed-off.txt
ERROR: --require-hashes and --no-require-hashes are mutually exclusive

pip added --no-require-hashes for a legitimate case: mixing hashed requirements with local directories or VCS URLs that cannot be hashed. Our point is narrower. Never rely on implicit hash mode in an install you care about.

The other two flags close smaller gaps. --only-binary :all: refuses source distributions, so no setup.py runs while pip resolves. --no-deps makes pip install exactly the listed set, which a complete lock already describes. pip's secure installs guide opens with the reason all of this is opt-in: "By default, pip does not perform any checks to protect against remote tampering."

A dependency cooldown cannot live in requirements.txt at all. pip's --uploaded-prior-to filter is not one of the 17 allowed options:

Plain text
$ cat requirements.txt
--uploaded-prior-to P3D
requests
$ python -m pip install -r requirements.txt
ERROR: Invalid requirement: --uploaded-prior-to P3D
__main__.py: error: no such option: --uploaded-prior-to

Set the cooldown in pip.conf or PIP_UPLOADED_PRIOR_TO for unpinned installs. For a pinned file, apply it when you generate the file, which the next section covers.

How do you generate requirements.txt in Python?

#

You generate requirements.txt by resolving a short list of direct dependencies into a pinned, hashed file. The common tools are pip-compile --generate-hashes from pip-tools, uv pip compile --generate-hashes, and uv export --format requirements.txt inside a uv project. pip freeze > requirements.txt also produces a file, but pip freeze takes a snapshot of an environment rather than resolving your dependencies.

The difference shows on a single package. We started from a requirements.in containing one line, requests:

Plain text
$ pip-compile --generate-hashes --strip-extras -o requirements.txt requirements.in
$ grep -c -- '--hash' requirements.txt
180
$ grep -A4 '^requests' requirements.txt
requests==2.34.2 \
    --hash=sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0 \
    --hash=sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed
    # via -r requirements.in

One input line became five pinned packages and 180 hashes. charset-normalizer alone accounted for 172 of them, one for each platform-specific wheel plus the source distribution. uv pip compile --generate-hashes produced the same five pins and the same 180 hashes. A hashed requirements.txt is unreadable by hand by design, and the only safe way to change it is to regenerate it.

pip freeze records something else:

Plain text
$ python -m pip install requests
$ python -m pip uninstall -y requests
$ python -m pip freeze
certifi==2026.10.23
charset-normalizer==3.5.2
idna==3.20
urllib3==2.8.0

pip freeze lists everything installed, including the four orphans left behind after we removed requests. pip freeze records no hashes and no sources. In the environment from the remote-include demo, pip freeze printed acme-billing==9000.0.0, with nothing to say the package came from the wrong index.

A cooldown belongs at this step. pip-compile --pip-args "--uploaded-prior-to P10D" resolved certifi 2026.10.19 instead of the 2026.10.23 release from a week earlier, and uv pip compile --exclude-newer "10 days" did the same. Malware that is reported and removed inside the window never reaches a pin locked that way, and the window is usually long enough: PyPI reported in 2024 that since August 2023 it had resolved over 90% of malware reports in under 24 hours.

MethodPins transitive dependenciesHashesRecords why each package is presentVerdict
Hand-written namesNoNoNoUse only as input, in requirements.in
pip freezeWhatever happens to be installedNoNoDebugging snapshots only
pip-compile --generate-hashesYesYes, every fileYes, # via commentsDefault for pip-based applications
uv pip compile --generate-hashesYesYes, every fileYes, # via commentsSame result, faster resolver
uv export --format requirements.txtYes, from uv.lockYes, by defaultYes, # via commentsWhen uv.lock is the real lock and another tool needs requirements.txt

Our verdict: write direct dependencies in requirements.in, generate requirements.txt with a resolver that emits hashes, and commit both. Use pip freeze to inspect an environment, never to define one.

Does pinning with == make requirements.txt safe?

#

No: an == pin in requirements.txt fixes a version number, but pip accepts any file with that version from any configured index. Figure 2 shows the experiment.

Figure 2 in text: two local indexes both served acme-billing 1.4.2. The private copy had sha256 e10dd02fc96068e5fb7e34a21c1ee83cf3900eedda759eb828920a1f6c3597a6. The public stand-in had sha256 18171155c059b8289a3efeae396b3f7d4ab360a211c77f0345ca1d15a2d56d52 and also served 9000.0.0. The results for three versions of the same requirement:

Requirement lineFiles that matchResult with pip 26.2.2
acme-billing==1.4.2Private 1.4.2 and public 1.4.2Public 1.4.2 installed, 3 of 3 runs
acme-billing==1.4.2 --hash=sha256:e10dd02f... (index publishes hashes)Private 1.4.2 onlyPrivate 1.4.2 installed
Same line, index publishes no hashesUnknown until downloadPublic file downloaded, hash mismatch, install fails

The unhashed pin used this file:

Plain text
--index-url http://localhost:8011/
--extra-index-url http://localhost:8012/
acme-billing==1.4.2

pip install --dry-run --report confirmed that the chosen download URL came from the second index. Swapping the two index lines flipped the result, so in pip 26.2.2 a tie between identical versions went to the index listed last. pip's documentation promises no order: "There is no priority in the locations that are searched." Treat the tie-break as an implementation detail. uv 0.13.6 also installed the public copy, because uv searches --extra-index-url entries before --index-url.

Adding the private file's hash changed the outcome. With the hash present, pip discarded the public 1.4.2 and installed the private one. pip can do this when the index advertises each file's digest in its links, as PyPI's Simple API does. We then served the same two directories as plain listings with no digests, on ports 8001 and 8002, and kept the hashed line. pip downloaded the public file first and then stopped:

Plain text
ERROR: THESE PACKAGES DO NOT MATCH THE HASHES FROM THE REQUIREMENTS FILE. If you have updated the package versions, please update the hashes. Otherwise, examine the package contents carefully; someone may have tampered with them.
    acme-billing==1.4.2 from http://localhost:8002/acme-billing/acme_billing-1.4.2-py3-none-any.whl (from -r requirements.txt (line 3)):
        Expected sha256 e10dd02fc96068e5fb7e34a21c1ee83cf3900eedda759eb828920a1f6c3597a6
             Got        18171155c059b8289a3efeae396b3f7d4ab360a211c77f0345ca1d15a2d56d52

Failing closed is the correct result. A broken build is recoverable, and an impostor in production may not be.

The two-index form of this attack has already happened. Between December 25 and December 30, 2022, Linux users who installed PyTorch nightly builds with pip received a malicious torchtriton from PyPI instead of from PyTorch's own index. PyTorch's disclosure said that pip "will install their version by default." The malicious binary ran when triton was imported and uploaded SSH keys and the first 1,000 files in the user's home directory over DNS.

What a hashed requirements.txt does not cover

#

Hashes in requirements.txt prove integrity, not trust. A hash proves that you received the bytes you locked. If you lock a malicious release, hash-checking mode reinstalls it faithfully on every build.

The LiteLLM compromise shows how small the window can be. LiteLLM 1.107.0 reached PyPI at 11:39:51 UTC on October 21, 2026, as seven wheels carrying a cook.pth startup hook and a credential collector. Socket's scanner flagged the release at 11:46:30 UTC. Any requirements.txt regenerated while 1.107.0 was available, from an open-ended line such as litellm or litellm>=1.100, would have locked it, and its hashes would have made the malware reproducible. A cooldown at generation time narrows this gap, provided the malicious release is caught before the cooldown expires.

The second gap is everything the file does not list. After the Telnyx Python SDK compromise in March 2026, our first recommendation was to audit requirements.txt files for telnyx==4.87.1 or telnyx==4.87.2. That check only answers for installs that used the file. A RUN pip install telnyx line in a Dockerfile, a notebook cell, or a CI step that installs a tool directly never touches requirements.txt. We made the same argument about lockfiles after the axios compromise. A lockfile makes existing installs deterministic, not new installs safe, and a clean one checked after the fact is weak evidence that you were never exposed.

The control that covers both gaps runs before the download. Socket Firewall Free wraps the install command, as in sfw pip install -r requirements.txt, and runs pip behind a local proxy that checks each requested artifact against Socket's malware data before pip receives it. It has documented limits. sfw cannot see packages pip serves from its local cache, so the documentation says to run pip cache purge first. The free version blocks only malware confirmed by human review, warns on AI-detected malware, and does not block versions Socket has not yet scanned.

requirements.txt vs pyproject.toml vs pylock.toml

#

requirements.txt competes with two standard formats, and each answers a different question. The table compares them as of October 2026.

FormatWhat it recordsHashesStandardCan redirect package sourcesInstaller support
pyproject.toml [project.dependencies]Version ranges a project supportsNoYes, PEP 621Not in [project]; tool tables such as [tool.uv.index] canEvery modern build tool and installer
requirements.txtPackages and pip options for one installOptional, and the file itself can relax enforcementNo, pip conventionYes, from the file and from any includepip, uv, pip-tools, most scanners
pylock.tomlExact files to install, with their sourcesRequiredYes, PEP 751No, each package records its own sourcepip 26.1+ reads it with -r, still marked experimental; uv and PDM export it
uv.lock, poetry.lockTool-specific lock of a resolved graphYesNoNo, sources are recorded per packageThe tool that wrote it

Our verdict: libraries declare ranges in pyproject.toml and ship no requirements.txt. Applications installed with pip should use a generated, hashed requirements.txt today and move to pylock.toml when pip drops the experimental label. Applications managed by uv or Poetry should treat that tool's lock file as the source of truth and export requirements.txt only for tools that need it. Nobody should hand-edit a hashed requirements.txt.

The open problem: a dependency list that can rewrite its own sources

#

requirements.txt survives because it is simple and every Python tool can read it. Its weakness is the same flexibility. The file that lists packages can also redefine where packages come from, exempt a host from HTTPS with --trusted-host, pull in more instructions over HTTP, and, since pip 26.2, switch off the hash enforcement its own hashes turned on. pylock.toml removes this class of problem by design, because a lock file that records each file's URL and digest has no options to override.

We think pip should, at minimum, refuse index, trust, and hash-mode options from requirements files included over HTTP, where nobody reviewed them. Until it does, the safest requirements.txt contains nothing but pinned, hashed requirements, is written by a tool, and is installed with the strict flags on the command line.