Poetry Lock Files and Dependency Groups: Reproducible Installs Without the Docker Bloat
Poetry's lock file pins exact versions and hashes while dependency groups separate runtime from dev tooling. Here's how to use both for reproducible installs and slim production images.
19 Oct 2025, 10:35 UTC

Your CI pipeline just failed on a dependency that worked fine on your laptop last week. Meanwhile, your production Docker image ships pytest, black, and mypy — several hundred megabytes of tooling that will never run in production. Both problems have the same root cause: development and runtime dependencies treated as one undifferentiated pile, installed from loose version ranges.
Poetry solves this with two features that work together: the lock file, which pins exact versions and hashes for the entire dependency graph, and dependency groups, which separate what your application needs to run from what you need to develop it. This post shows how to use both, and where the rough edges are.
The lock file is the contract
When you run poetry add requests, Poetry writes a version range like ^2.31 into pyproject.toml, then resolves the full graph — requests plus everything requests depends on, recursively — and records the exact winning versions and their content hashes in poetry.lock.
That distinction matters. pyproject.toml expresses intent ("any compatible 2.x requests"); poetry.lock records fact ("requests 2.31.0, urllib3 2.0.7, certifi 2023.7.22, with these hashes"). When you run poetry install with a lock file present, Poetry installs exactly what the lock says — it does not re-resolve. Two commands people routinely conflate:
poetry install— respects the existing lock file. Use this everywhere you want reproducibility: fresh clones, CI, Docker builds.poetry update— throws away the resolution and re-solves against the ranges inpyproject.toml, rewriting the lock. Use this deliberately, when you want newer versions.
The lock file only works if you commit it and never hand-edit it. If a merge produces conflicts in poetry.lock, don't resolve them by editing text — accept one side, then run poetry lock (or poetry update on the affected packages) to regenerate a consistent file. A hand-merged lock can contain a graph that no solver would ever produce.
Dependency groups keep dev tooling out of production
Older Poetry versions had a single dev-dependencies section. Current versions generalize this into named groups. A typical pyproject.toml:
[tool.poetry.dependencies]
python = "^3.11"
requests = "^2.31"
[tool.poetry.group.dev.dependencies]
pytest = "^7.4"
ruff = "^0.1"
[tool.poetry.group.docs.dependencies]
mkdocs = "^1.5"Each group is resolved into the same lock file — so dev tools and runtime libraries can never disagree about a shared transitive dependency — but installed selectively. The two flags you actually need:
poetry install --only main— runtime dependencies only. This is the standard pattern for production images.poetry install --without docs— everything except the docs group, handy when you want dev tools but not documentation builds.
By default, poetry install installs the main group plus any non-optional groups, which is why a bare install on your laptop gives you pytest. One caveat: which groups install by default, and the exact flag spellings, have shifted across Poetry releases. Run poetry install --help on your installed version before writing flags into CI scripts, and pin your Poetry version in CI so the behavior doesn't drift underneath you.
A worked example: slim Docker build
Here's the pattern in a Dockerfile, run from the repository root with pyproject.toml and poetry.lock committed:
FROM python:3.11-slim
WORKDIR /app
RUN pip install "poetry==1.7.1"
COPY pyproject.toml poetry.lock ./
RUN poetry config virtualenvs.create false \
&& poetry install --only main --no-interaction --no-ansi
COPY . .
CMD ["python", "-m", "myapp"]Three things to notice. First, Poetry itself is pinned (poetry==1.7.1 — substitute the version you develop with), because install flags and defaults vary by release. Second, virtualenvs.create false installs into the system Python, which is fine inside a container that exists to run one app. Third, --only main means pytest and ruff never enter the image.
To verify the split actually worked, run this locally (no special permissions needed, in the project directory):
poetry install --only main
poetry run python -c "import pytest" # should fail with ModuleNotFoundErrorIf pytest imports, your dev group leaked into the install — check that the packages are under [tool.poetry.group.dev.dependencies] and not the main section. To verify reproducibility, the stronger test is: commit the lock file, delete the virtualenv (poetry env remove python), run poetry install, and confirm the same versions come back via poetry show.
Trade-offs worth knowing
Poetry's solver does real constraint solving, and on large graphs with loose constraints (say, "*" or wide-open ranges on packages with many releases) resolution can take minutes. Tightening constraints to realistic ranges speeds this up noticeably. The lock file also only helps people who use Poetry — if you need to hand dependencies to an environment that can't run it, the poetry export plugin can generate a requirements.txt, but you lose some fidelity (group selection and hashes depend on export flags, and it's one more artifact to keep in sync).
Finally, reproducibility is only as good as your discipline: an uncommitted or hand-edited lock file is worse than none, because it looks like a guarantee it isn't.
Where to start
If you take one action: commit poetry.lock today, move test and lint tools into a dev group, and change your production build to poetry install --only main. Then run the import check above to prove the split works. You'll get smaller images and builds that produce the same environment every time — and the next "works on my machine" failure will have one less place to hide.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.