poetry.lock is what makes a Poetry project reproducible: it pins every package — direct and transitive — to an exact version with file hashes, so a contributor's laptop, the CI runner and the release build all install the same set. It is also the file behind most day-to-day Poetry confusion. Why did poetry install change the lock? Why does CI say the lock is out of date? Why did poetry update touch thirty packages when I wanted one? What do I do with a merge conflict in a two-thousand-line generated file? This guide answers those for a CLI project: which command changes what, how to keep CI strict, how to upgrade on purpose, and how to recover when the lock and pyproject.toml disagree. It belongs to the Poetry workflows topic.
Prerequisites
- Poetry 2.x and a CLI project with a committed
poetry.lock. - The dependency-group layout from Poetry dependency groups for CLI tooling helps but is not required.
Which command changes what
Four commands touch dependencies, and they differ in whether they resolve, whether they write the lock, and whether they change the environment:
poetry lockresolvespyproject.tomland writespoetry.lock. Since Poetry 2.0 it keeps already-locked versions wherever they still satisfy the constraints, so it is safe to run after editingpyproject.toml;poetry lock --regeneratethrows the old lock away and re-resolves from scratch.poetry update [packages]re-resolves and moves versions forward — for the named packages, or for everything — then writes the lock and installs.poetry installinstalls from the lock into the environment (locking first only if no lock exists). It adds what is missing but leaves extra packages alone.poetry syncinstalls from the lock and removes anything the lock does not contain, so the environment matches exactly. It replaces Poetry 1'spoetry install --sync.
poetry add and poetry remove combine editing pyproject.toml, locking and installing in one step, which is why everyday work rarely needs poetry lock directly.
The recipe
Keep CI strict
poetry check --lock # fail if pyproject.toml changed since the lock was written
poetry sync --no-interaction # environment exactly matches poetry.lock
poetry run pytest
poetry check --lock is the important line. When someone edits a constraint in pyproject.toml by hand and forgets to re-lock, it fails with:
Error: pyproject.toml changed significantly since poetry.lock was last generated. Run `poetry lock` to fix the lock file.
Without the check, CI would install from the stale lock and test something that does not match the declared constraints. poetry sync rather than poetry install keeps CI environments free of leftovers when a cached virtual environment is reused.
Upgrade on purpose
poetry show --outdated --top-level # what is behind, direct dependencies only
poetry update click --dry-run # preview one targeted upgrade
poetry update click # apply it
poetry update # move everything within constraints
Targeted updates keep lock diffs small and reviewable — the right tool for a security fix found by auditing dependencies with pip-audit (export first with the poetry-plugin-export plugin). A full poetry update belongs in its own pull request, ideally opened by Dependabot or Renovate on a schedule, with the test suite as the judge. If an upgrade is blocked by a constraint in pyproject.toml, poetry update will not cross it; change the constraint deliberately (poetry add "click@^9.0") so the decision is visible in review.
Resolve merge conflicts without editing the lock
Two branches that both changed dependencies will conflict in poetry.lock. Never resolve that by hand — the file contains hashes and a content hash of pyproject.toml that must agree. Instead:
git checkout --theirs poetry.lock # or --ours; either side is fine
# resolve any conflict in pyproject.toml by hand first, then:
poetry lock # re-resolve against the merged pyproject.toml
poetry check --lock && git add poetry.lock
Because poetry lock keeps existing versions where it can, the result changes only what the merged constraints require.
Make installs reproducible beyond Poetry
Some environments — a Docker image, a system without Poetry — need a plain requirements file. The export plugin produces one from the lock, with hashes:
poetry self add poetry-plugin-export
poetry export --only main -f requirements.txt -o requirements.txt
pip install --require-hashes --no-deps -r requirements.txt
--only main leaves development groups out, so the file describes exactly what users of the CLI need. Hash checking then refuses any file that differs from what was locked, as explained in pinning dependencies with hashes.
Why the Python range matters to the lock
Poetry resolves the lock for every Python version your requires-python allows, not just the interpreter on your machine. That is what makes one lock file valid for a teammate on Python 3.11 and a CI runner on 3.13 — and it is also the most common cause of a confusing resolution failure. If requires-python = ">=3.10" and a dependency's newest release requires 3.11, Poetry either picks an older release of that dependency for everyone or reports that no version satisfies all supported Pythons.
Three habits avoid the surprise. Keep requires-python as narrow as your support policy really is — claiming support for versions you never test only makes resolution harder, as discussed in pinning the Python version for a CLI. When you raise the minimum Python, run poetry lock in the same change, since newer dependency releases may suddenly become eligible. And read the resolver's error message to the end: it names the package and the Python range that conflict, which is usually enough to decide between raising the minimum and adding an environment marker such as "tomli (>=2) ; python_version < '3.11'".
Reading a lock diff
Each package appears in poetry.lock as a [[package]] table with its name, version, the Python range it applies to, and the hashes of its files. In a pull request, three things deserve a glance: new package names you did not add on purpose (a dependency gained a dependency — make sure it is the package you think it is), large version jumps in a "small" update, and changes to the [metadata] section without a matching pyproject.toml change, which often means someone locked with a different Poetry version — another reason to pin it.
UX considerations
The users of a lock file are contributors, and a few conventions spare them most of the pain:
- Commit the lock. A CLI is an application; its lock belongs in version control so every environment agrees.
- Pin Poetry itself in CI and in
CONTRIBUTING.md. Lock files written by different Poetry versions can differ in format, and a version mismatch produces noisy diffs. - Collapse it in review with
poetry.lock linguist-generated=truein.gitattributes, while keeping it reviewable on demand. - Explain the commands once. A short table in
CONTRIBUTING.md— add, remove, lock, update, sync — prevents most "why did my lock change?" questions. - Check before releasing.
poetry check --lockbelongs in the release script too, as in building and publishing a CLI with Poetry.
Testing the behaviour
The guarantee worth testing is that CI catches a stale lock. A small test inside the suite does the same check as the CI step, so contributors find out locally:
# tests/test_lockfile.py
import shutil
import subprocess
import pytest
@pytest.mark.skipif(shutil.which("poetry") is None, reason="poetry not installed")
def test_lock_file_matches_pyproject():
result = subprocess.run(["poetry", "check", "--lock"], capture_output=True, text=True)
assert result.returncode == 0, result.stdout + result.stderr
To see the CI side fail once, change a version constraint in pyproject.toml on a branch without running poetry lock, push, and confirm the job stops at poetry check --lock with the message above. That one rehearsal is usually enough to make the check trusted.
Conclusion
poetry.lock stays painless when everyone knows which command does what: poetry lock resolves while keeping existing versions, poetry update moves versions forward, poetry install adds and poetry sync makes the environment exact. Enforce poetry check --lock and poetry sync in CI, upgrade with targeted poetry update calls, resolve conflicts by re-locking rather than editing, and export a hashed requirements file wherever Poetry is not available.
Frequently asked questions
Why did poetry install change my lock file?
It should not, if a lock exists and matches pyproject.toml. If pyproject.toml changed, Poetry 2 refuses to install from a stale lock and asks you to run poetry lock first; older Poetry versions behaved differently, which is one reason to pin the version.
Is poetry lock --no-update still needed?
No. Poetry 2.0 made "keep locked versions" the default for poetry lock, so --no-update was removed; use --regenerate when you really want a fresh resolution.
How do I see why a package is in the lock?
poetry show --tree prints the dependency tree, and poetry show <package> lists what requires it. That is the quickest way to judge whether a transitive dependency can be avoided.
Should the lock include development tools?
Yes — dependency groups are locked together with the main dependencies, which keeps linters and test tools consistent across the team. Installs choose what to include with --only, --with and --without.