Dependency Management
Dependency management is about keeping a project's full set of required packages — direct and transitive — resolved, pinned, and reproducible over time.
Every project depends on more than just the packages it names directly: each of those packages brings its own dependencies, and so on. Dependency management is the discipline of tracking this whole graph deliberately — deciding what versions are allowed, freezing what actually gets installed, and resolving conflicts when two requirements can't both be satisfied.
Direct Versus Transitive Dependencies
A direct dependency is a package your project imports and explicitly declares (e.g. in pyproject.toml's dependencies list). A transitive dependency is a package that a direct dependency itself requires, pulled in automatically without your project ever naming it.
The full set actually installed — direct plus every transitive dependency, at specific resolved versions — is what needs to be reproducible, not just the short list you wrote by hand.
# Your project declares:
dependencies = ["requests>=2.28"]
# requests itself depends on:
# charset-normalizer, idna, urllib3, certifi
# -> those four are transitive dependencies of your project,
# installed automatically even though you never named them.Why Loose Direct Constraints Aren't Enough
requests>=2.28 in pyproject.toml is a *range*, not a specific install. Two runs of pip install months apart could legitimately resolve that range to different concrete versions of requests — and different transitive versions beneath it — as new releases appear. That's fine for a library (which should tolerate a range of versions of its dependencies) but dangerous for an application you want to behave identically today and next year.
Lock Files
A lock file records the exact, fully-resolved version of every package in the dependency graph — direct and transitive — usually along with cryptographic hashes, so that install from the lock file reproduces byte-for-byte the same set of packages on any machine, regardless of what's newly available on the index that day.
The standard library and pip alone don't produce a true lock file automatically; the ecosystem fills this gap with third-party tools: pip-tools (pip-compile turns loose requirements into a fully pinned, hash-locked requirements.txt), Poetry (poetry.lock), and PDM (pdm.lock), among others.
# pip-tools workflow (third-party tool)
# requirements.in — loose, human-authored:
requests>=2.28
click
pip-compile requirements.in
# -> requirements.txt: every package, exact version, with hashes
pip-sync requirements.txt
# -> installs EXACTLY what's locked, removing anything extraReproducible Environments
'Reproducible' means: given the same lock file, a fresh install on any machine (same OS/platform family) produces the same set of installed package versions, not merely 'compatible' versions. This matters most for applications and deployed services, where an untracked transitive-dependency upgrade breaking production is a real, recurring failure mode in practice.
python -m venv .venv
source .venv/bin/activate
pip install -r requirements.txt --require-hashes # locked + hash-verifiedDependency Pinning
Pinning means constraining a dependency to an exact version (requests==2.31.0) rather than a range. The tradeoff: pinning direct dependencies in an application's lock file gives reproducibility, but pinning too aggressively in a *library's* own declared dependencies can make that library hard to combine with other libraries that need a different exact pin of the same package — this is why libraries typically declare open ranges and let the *application* that finally assembles everything do the exact pinning via its lock file.
# Library's pyproject.toml — open range, cooperative
dependencies = ["requests>=2.28,<3"]
# Application's lock file — exact pin, reproducible
requests==2.31.0Dependency Conflicts
A conflict arises when two packages in the same environment require mutually incompatible versions of a shared dependency — e.g. package A needs urllib3>=2.0 while package B needs urllib3<2.0. Modern pip (2020+, the '2020 resolver') detects this and refuses to silently install a broken combination; it reports which requirements are in conflict instead.
Resolving a real conflict usually means: upgrading/downgrading one of the conflicting top-level packages to a version whose own requirement range has been relaxed, dropping one of the packages, or — as a last resort and only if you've verified it's safe — pinning the shared dependency yourself and hoping both packages tolerate it in practice even though their declared metadata disagrees.
ERROR: Cannot install package-a and package-b because these
package versions have conflicting dependencies.
package-a 1.0.0 depends on urllib3>=2.0
package-b 3.2.0 depends on urllib3<2.0Inspecting the Dependency Graph
pipdeptree is a third-party tool, not part of pip or the standard library, but it's a common way to see the full direct+transitive tree at a glance.
pip show requests # requests' own direct dependencies
pip check # verifies the whole installed graph is consistent
python -m pip install pipdeptree # third-party: visualizes the full tree
pipdeptreePractical Usage
A workflow that separates 'what I want' from 'what's actually installed':
# requirements.in — loose, human-edited, direct dependencies only
flask>=3.0
requests>=2.28
# generated, machine-owned, committed to version control:
pip-compile requirements.in -o requirements.txt
# every install, everywhere, uses the generated file:
pip-sync requirements.txtEdge Cases
A dependency can be required by two different paths in the graph at different versions (a 'diamond dependency') — the resolver must pick one version that satisfies both paths' constraints, or fail if none exists. Pre-release versions (2.0.0a1) are excluded from normal resolution unless explicitly requested, so a project accidentally depending on a pre-release feature can fail to resolve for other users. Removing a direct dependency from your code without removing its declaration leaves an orphaned pin nobody's tracking the reason for.
Best Practices
Separate the human-edited 'what I want' file from the machine-generated 'exact locked graph' file, and commit both. Use a lock file (via pip-tools, Poetry, PDM, or similar) for any application meant to run reliably in more than one place or at more than one time. Keep libraries' declared ranges as open as is safely compatible. Re-generate the lock periodically (not just on demand) to pick up security fixes deliberately, rather than accidentally. Read resolver conflict errors carefully — they name the exact conflicting requirements.
Complete Example
# requirements.in
requests>=2.28,<3
flask>=3.0
# Generate the lock (third-party pip-tools):
pip install pip-tools
pip-compile requirements.in
# -> requirements.txt now lists requests, flask, AND every transitive
# dependency (urllib3, idna, certifi, click, jinja2, ...) pinned exactly
# Reproduce it anywhere:
python -m venv .venv && source .venv/bin/activate
pip-sync requirements.txt
# Verify no conflicts:
pip checkQuick Reference
Direct dependency: what YOUR project declares
Transitive dependency: what a dependency itself requires
Pinning: exact version constraint (==X.Y.Z)
Lock file: fully resolved, exact graph (direct + transitive)
Reproducible env: same lock -> same installed versions, every time
pip check verify installed graph is consistent
pip-compile requirements.in generate a locked requirements.txt
pip-sync requirements.txt install exactly what's lockedKey Takeaways
Your project's real dependency footprint includes every transitive dependency, not just the packages you named.
A lock file freezes the entire resolved graph at exact versions, enabling true reproducibility — the standard library and pip alone don't generate one automatically; pip-tools, Poetry, and PDM (all third-party) do.
Libraries should declare open, compatible version ranges; applications should pin exactly via a lock file.
Modern pip's resolver detects and reports genuine version conflicts rather than silently installing a broken combination.
Separate the human-authored 'what I want' file from the machine-generated 'exact locked graph' file, and commit both.
dependencies = ["requests"] # a LIBRARY pinning exactly dependencies = ["requests==2.31.0"] dependencies = ["requests>=2.28,<3"] # open, compatible range
A library that pins exact versions of its own dependencies can become impossible to install alongside another library needing a different exact version of the same package.
pip-compile requirements.in -o requirements.txt # locked, exact, hashed pip-sync requirements.txt
Without a lock file, an application's transitive dependencies can silently drift between installs, breaking reproducibility even when the direct requirements file hasn't changed.
--no-deps silences the resolver rather than fixing the underlying incompatibility, risking a broken install that merely fails less loudly.
What is the difference between a direct and a transitive dependency?
A direct dependency is one your project explicitly declares and imports; a transitive dependency is something a direct dependency itself requires, pulled in automatically without your project naming it.
Why should a library generally avoid pinning exact versions of its own dependencies?
Exact pins in a library constrain everyone who installs it, which can make the library impossible to combine with other libraries needing a different exact pin of the same shared dependency — open ranges are more cooperative.
What does a lock file provide that a loose requirements.txt does not?
It records the fully resolved, exact version of every package in the graph (direct and transitive), often with hashes, so installs are byte-for-byte reproducible rather than merely 'compatible' with a range.
Give an example of a dependency conflict and what causes it.
Package A requires urllib3>=2.0 while package B requires urllib3<2.0 — since only one urllib3 version can be installed at a time, no version satisfies both, and pip's resolver reports the conflict rather than guessing.
Why is it good practice to separate a human-edited requirements.in from a machine-generated requirements.txt?
It keeps the 'what I intentionally want' declarations separate from and much smaller than the full resolved graph, so intent stays legible while the lock file still captures every exact transitive version.
Are pip-tools, Poetry, and PDM part of Python's standard library?
No — all three are third-party/ecosystem tools; pip and the standard library alone do not generate a true lock file.
Write a short requirements.in (loose, direct-only) for an application needing flask 3.x and requests 2.28+, then the command to compile it into a locked requirements.txt using pip-tools.
requirements.in states only the direct, intentional dependencies with open ranges; pip-compile resolves the full transitive graph and writes exact pinned versions (plus, typically, hashes) into requirements.txt.
Why will pip's resolver refuse this combination, and name one way to fix it.
No single installed urllib3 version can satisfy both >=2.0 and <2.0 at once, so the resolver reports a conflict instead of guessing; a fix is to upgrade/replace package-b to a version whose urllib3 requirement has been relaxed to include 2.x.
Write a comment explaining why 'pip install -r requirements.txt' run today versus six months from now could install different transitive dependency versions if requirements.txt only pins direct dependencies loosely.
Open ranges leave room for the resolver to pick newer releases as they become available, so two installs at different times can land on different transitive versions unless everything is exactly pinned via a lock file.
package-a 1.0.0 requires urllib3>=2.0
package-b 3.2.0 requires urllib3<2.0
# both are direct dependencies of your project