Back to glossary

Poetry (Python)

Poetry is a Python dependency manager and packaging tool that locks pyproject.toml dependencies into a hashed poetry.lock, then builds and publishes packages.

Also known as Python Poetry, Poetry package manager, python-poetry, poetry.lock, poetry-core

Poetry is a Python dependency manager and packaging tool. It reads a project's dependencies from pyproject.toml, resolves them for every platform into a poetry.lock file that pins each version and SHA-256 file hash, installs that lock into a virtual environment, and builds and publishes packages. Its lock secures installs; locking and updating remain the risky steps.

We ran Poetry 2.5.1, the current release as of October 15, 2026, against two local package indexes: one playing a company's internal index, the other playing PyPI. Three results define its security model. With both indexes configured as primary sources, Poetry chose an attacker's acme-billing 99.0.0 over the internal 1.2.0. poetry lock executed a source distribution's setup.py twice before anything was installed, and installer.only-binary did not stop it. A 16-line hand edit to poetry.lock passed poetry check --lock and installed a package from an arbitrary URL.

Our position: poetry.lock is a strong install-time control, and the commands that write it are where a Poetry project's supply chain is open. Pin every internal package to an explicit source, set a release-age floor, refuse source builds where you can, lock in a sandbox without credentials, and review poetry.lock diffs as code.

What is Poetry in Python?

#

Poetry is one command-line tool that replaces the separate pieces Python packaging accumulated: pip for installing, venv for isolation, pip-tools for pinning, setuptools for building, and twine for uploading. Poetry 0.1.0, by Sébastien Eustace, reached PyPI on February 28, 2018, and 1.0.0 followed on December 12, 2019 (PyPI release history).

The difference from pip is the unit of work. pip installs packages into whatever environment is active and remembers nothing about why. Poetry manages a project: you declare what the project needs, and Poetry makes a dedicated virtual environment match that declaration exactly.

Poetry ships as two packages. The poetry CLI resolves, locks, installs, and publishes. poetry-core is the PEP 517 build backend that turns a Poetry project into a wheel or sdist, and it is what build-backend = "poetry.core.masonry.api" refers to in a project's pyproject.toml.

Poetry 2.0, released January 5, 2025, adopted the standard [project] table from PEP 621, so new projects declare metadata in the format every standards-based Python tool reads (changelog). Older projects keep [tool.poetry.dependencies], and Poetry 2.x reads both.

Poetry was the second most common publishing tool in the 2023 Python Developers Survey. Among respondents who publish packages, 33% used Poetry and 53% used twine.

How do you use Poetry in Python?

#

Using Poetry in Python comes down to four commands: poetry new (or poetry init in an existing directory), poetry add, poetry install, and poetry run. This is a real session with Poetry 2.5.1, which we installed in its own virtual environment so that Poetry's dependencies stay out of the project:

Bash
$ poetry new demo && cd demo
$ poetry add requests
Creating virtualenv demo in /root/work/demo/.venv
Using version ^2.34.2 for requests

Updating dependencies
Resolving dependencies...

Package operations: 5 installs, 0 updates, 0 removals

  - Installing certifi (2026.7.22)
  - Installing charset-normalizer (3.5.2)
  - Installing idna (3.20)
  - Installing urllib3 (2.8.0)
  - Installing requests (2.34.2)

Writing lock file
$ poetry run python -c "import requests; print(requests.__version__)"
2.34.2

poetry add did three things. It wrote "requests (>=2.34.2,<3.0.0)" into [project].dependencies, resolved the full dependency tree, and wrote poetry.lock. The upper bound is a Poetry default: its caret constraint ^2.34.2 becomes <3.0.0. That protects an application from the next major release, but in a library it forces version conflicts on downstream users. It is a compatibility choice, not a security control.

The rest of the daily workflow maps to a few more commands. poetry install installs exactly what the lock says, and poetry sync also removes anything the lock does not list. poetry update re-resolves against the live indexes and rewrites the lock. Since 2.0, poetry lock keeps already-locked versions where it can, and poetry lock --regenerate restores the old resolve-everything behavior. poetry build and poetry publish produce and upload distributions.

Keep Poetry itself isolated, with pipx or the official installer. Installing Poetry 2.5.1 with pip on Linux pulled in 45 dependencies, and all of them run in the process that holds your repository credentials.

poetry.lock pins files, not just versions

#

A poetry.lock entry records far more than a version. This is the entry for requests from the session above:

Plain text
[[package]]
name = "requests"
version = "2.34.2"
description = "Python HTTP for Humans."
optional = false
python-versions = ">=3.10"
groups = ["main"]
files = [
    {file = "requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0"},
    {file = "requests-2.34.2.tar.gz", hash = "sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed"},
]

[package.dependencies]
certifi = ">=2023.5.7"
charset_normalizer = ">=2,<4"
idna = ">=2.5,<4"
urllib3 = ">=1.26,<3"

The lock is universal. Poetry resolves once for every platform and Python version the project supports, not just for the machine running the command, so one lock serves a Mac laptop and a Linux CI runner. The cost is size: our five-package lock held 180 file hashes, and 172 of them belonged to charset-normalizer, one per wheel and sdist.

Three properties follow from that design.

poetry install does not resolve. Since Poetry 2.3.0, released January 18, 2026, installer.re-resolve defaults to false, and the installer only evaluates the environment markers already recorded in the lock.

Every download must match a locked hash. Poetry 1.1.9, released September 18, 2021, fixed "a security issue where file hashes were not checked prior to installation." Poetry 2.4.2, released August 29, 2026, closed a gap where Poetry installed an artifact missing from the lock when the source supplied no hash (changelog).

The files array is an allowlist. A file added to a release after you locked, such as a new platform wheel, is not on the list, so Poetry will not install it.

The lock only protects commands that read it. We locked acme-billing 1.2.0 from an internal index, then added a public index that served an attacker's 99.0.0. The lock kept 1.2.0 through a poetry lock run after the new source was added. The next poetry update switched the project to 99.0.0. A lockfile also cannot describe Poetry itself or tools installed with pipx, because neither is in it.

poetry.lock is a Poetry-specific format. Python standardized a common one, pylock.toml, when PEP 751 was accepted on March 31, 2025, and Poetry 2.3.0 can export to it through poetry-plugin-export.

Where package code runs in a Poetry workflow

#

Package code can run at four points in a Poetry workflow, and the first comes before anything is installed. A wheel is a prebuilt archive, and installing one copies files without running any of the package's code. A source distribution (sdist) is source code plus a build recipe, and turning it into something installable means running its build backend, which for setuptools projects means executing setup.py. Copying files is not automatically safe either: CVE-2026-34591 let a crafted wheel write files outside the target environment in Poetry 1.4.0 through 2.3.2, fixed in 2.3.3 (advisory).

StepCommandsWhen package code runsEffect of installer.only-binary
1. Resolvepoetry add, poetry lock, poetry updateAn sdist without static metadata has its build backend run so Poetry can read its dependenciesNone
2. Installpoetry install, poetry syncWheels run no code. A locked sdist is built, running its backend, when no locked wheel fits the machineBlocks the build and fails the install
3. ToolingAny later poetry commandProject plugins from requires-plugins load inside Poetry's own processNone
4. Runtimepoetry run, pythonImporting a package runs its module code, and .pth files run at every interpreter startNone

Step 1 is the one that surprises people, because no install has happened yet. Steps 1 and 3 are also the ones Poetry's binary-only setting does not reach. The next two subsections test them.

poetry lock can run a dependency's build code

#

Poetry needs every candidate's dependency list to resolve. When the only distribution is an sdist whose PKG-INFO predates static metadata, Poetry asks the package's build backend for it. Core metadata 2.2 (PEP 643) lets an sdist mark its metadata as static, so a tool can read PKG-INFO without building. We tested against a PEP 503 simple index, the protocol most private registries serve. PyPI's JSON API supplies metadata for many packages, so Poetry builds less often there.

We published fastparse-utils 0.1.0 to our local public index as an sdist only, with Metadata-Version 2.1 in PKG-INFO and a setup.py that leaves a marker file each time it runs:

Python
# setup.py inside fastparse_utils-0.1.0.tar.gz
import os, sys, time
with open(f"/tmp/sdist-ran-{int(time.time())}-{os.getpid()}", "w") as f:
    f.write(" ".join(sys.argv) + "\n")
from setuptools import setup
setup(name="fastparse-utils", version="0.1.0", packages=["fastparse_utils"])

Then we counted the markers after each command, with no cached builds:

CommandSettingsetup.py runs
poetry lockdefault2 (egg_info, then dist_info)
poetry lockinstaller.only-binary = :all:2
poetry installinstaller.only-binary = :all:0, and the install fails
poetry installdefault2 (egg_info, then bdist_wheel)
poetry locksdist declares Metadata-Version 2.20
uv lockdefault2
uv lock--no-build0, and resolution fails

installer.only-binary protects poetry install, not poetry lock. Poetry's configuration docs say the binary settings are "not taken into consideration when a lockfile is generated or dependencies are resolved." uv has the lock-time switch Poetry lacks.

The Metadata-Version 2.2 row is not a defense, because the attacker writes PKG-INFO. Build backends are a live delivery path. In the MemTensor compromise on September 23, 2026, the malicious MemoryOS 2.0.34 sdist replaced its standard Poetry build backend with a bundled sckit_poetry_build.py. Our analysis found those pieces aimed at the project's release pipeline, but any build of that sdist runs the attacker's file instead of poetry-core.

Refusing source builds has a cost. Packages that publish no wheel for your platform, and private packages that ship only sdists, will fail until you list them in installer.no-binary as exceptions. Poetry offers no lock-time switch, so the mitigation for locking is where you run it: a container or CI job with no credentials, so that code which runs during resolution has nothing to steal.

Project plugins run inside Poetry

#

Poetry 2.0 added [tool.poetry.requires-plugins], which installs the plugins a project needs into .poetry/plugins in the project directory (Poetry docs). We declared a path plugin whose activate() method writes a marker file, then ran commands in order:

Command, in orderPlugin activations so far
poetry check0
poetry show0
poetry install0, but it prints "Installing Poetry plugins only for the current project..."
poetry show1
poetry lock2
poetry env info -p3
poetry --version4

After one poetry install, every Poetry command in that directory runs the plugin, including poetry --version. The plugin runs inside Poetry's process, which reads repository credentials from the system keyring or, when no keyring is available, from a plaintext auth.toml (Poetry docs). Review requires-plugins the way you review a CI workflow, and treat poetry install in an unfamiliar repository like running its test suite.

How does Poetry choose between package sources?

#

Poetry chooses between package sources by priority class. Primary sources come first, or the implicit PyPI source when no primary source is configured. Supplemental sources come next. Explicit sources are consulted only for packages pinned to them (Poetry docs).

Inside the primary class, Poetry queries every source for every package, and in our test it took the highest compatible version. That is the condition dependency confusion needs: an attacker publishes your internal package name to the public index with a higher version number.

We tested it with two local indexes. internal served acme-billing 1.2.0, which depends on acme-core>=1.0, and acme-core 1.0.3. public stood in for PyPI and served attacker uploads of both names at 99.0.0. The project depended only on acme-billing.

Source configurationacme-billing lockedacme-core locked
internal and public both primary, internal listed first99.0.0 from publicNot locked, because 99.0.0 declares no dependencies
public primary, internal supplemental99.0.0 from publicNot locked
internal explicit, only acme-billing pinned to it1.2.0 from internal99.0.0 from public
internal explicit, both packages pinned to it1.2.0 from internal1.0.3 from internal

Listing the internal index first does not help. Poetry's documentation warns that a package published to a primary source "could coincidentally or intentionally replace your dependency with something you did not expect."

The third row is the easy mistake. Source pins are not inherited, so pinning acme-billing to the internal index still let its transitive dependency acme-core resolve from the public one. The configuration that held pins every internal name, including ones you never import:

Plain text
[[tool.poetry.source]]
name = "internal"
url = "https://pypi.internal.example/simple/"
priority = "explicit"

[tool.poetry.dependencies]
acme-billing = { source = "internal" }

[tool.poetry.group.internal-pins.dependencies]
acme-core = { version = "*", source = "internal" }

The cost is upkeep. The pin list has to track every internal package in the tree, and the first new internal transitive dependency someone forgets to pin resolves from PyPI. uv makes the safe behavior the default: its first-index strategy takes each package only from the first index that has it, and in the same test uv locked 1.2.0 and 1.0.3 from internal with no per-package pins (uv docs).

A poetry.lock diff is code

#

poetry check --lock verifies that the lock is fresh, not that it is honest. The content-hash in poetry.lock fingerprints the dependency sections of pyproject.toml. Nothing checks the lock's package entries against the registry.

We added 16 lines to the demo project's poetry.lock and left pyproject.toml untouched. The edit declares a new package served from an arbitrary URL, with that file's correct hash, and adds it as a dependency of requests:

Diff
+[[package]]
+name = "acme-core"
+version = "99.0.0"
+description = ""
+optional = false
+python-versions = "*"
+groups = ["main"]
+files = [
+    {file = "acme_core-99.0.0-py3-none-any.whl", hash = "sha256:378aac8dd5045f32bee0fb510f38d7a8b6eb67507e7eaf7923926a3cf90b7773"},
+]
+
+[package.source]
+type = "url"
+url = "http://localhost:8102/files/acme_core-99.0.0-py3-none-any.whl"
+
 [[package]]
 name = "certifi"
@@
 [package.dependencies]
+acme-core = ">=1.0"
 certifi = ">=2023.5.7"

Poetry accepted it and installed the package:

Plain text
$ poetry check --lock
All set!
$ poetry install
  - Installing acme-core (99.0.0 http://localhost:8102/files/acme_core-99.0.0-py3-none-any.whl)
  - Installing certifi (2026.7.22)
  - Installing charset-normalizer (3.5.2)
  - Installing idna (3.20)
  - Installing requests (2.34.2)
  - Installing urllib3 (2.8.0)
$ poetry show --tree
requests 2.34.2 Python HTTP for Humans.
├── acme-core >=1.0
├── certifi >=2023.5.7
├── charset-normalizer >=2,<4
├── idna >=2.5,<4
└── urllib3 >=1.26,<3

The real requests 2.34.2 declares no such dependency, and the hash check passed because the attacker wrote the hash. Reviewers are unlikely to catch it. GitHub's Linguist classifies poetry.lock as generated (generated.rb), and generated files are suppressed in pull request diffs.

Two cheap checks close most of this. Add poetry.lock -linguist-generated to .gitattributes so the diff is shown. In CI, run poetry lock and fail if the file changes: in our test it dropped the injected package and its dependency edge. That check needs network access, runs the same resolution code as step 1 above, and can flag harmless drift when an index adds files to a locked release. Socket's GitHub app also watches dependency manifests, lockfiles included, in pull requests and comments when a newly added dependency is a security risk (docs), and poetry.lock is among its supported inputs (ecosystem support).

Is Poetry more secure than uv?

#

Poetry is not more secure than uv by default. In our tests uv's defaults blocked the dependency confusion case that Poetry needed per-package pins for, and uv can refuse build code at lock time. Both tools execute sdist build code during locking unless told otherwise.

ControlPoetry 2.5.1uv 0.13.1
Several indexesHighest version across all primary sources; safe only with per-package source pinsfirst-index by default: each package comes from the first index that has it
Build code while lockingRuns sdist backends when metadata is missing; no setting stops itRuns them by default; --no-build refuses
Build code while installinginstaller.only-binary refuses--no-build refuses
Release-age floorsolver.min-release-age in days, since 2.4.0; a release with no upload time is not filteredexclude-newer date or duration; a file with no upload time is treated as unavailable
Lockpoetry.lock, universal, SHA-256 per fileuv.lock, universal, hash per file
Standard pylock.toml exportThrough poetry-plugin-export, since 2.3.0uv export --format pylock.toml
PublishingUsername and password or API token onlyTrusted publishing built in
Code inside the toolProject plugins installed from requires-pluginsNo plugin mechanism

The release-age floor shows the gap in miniature. With poetry config --local solver.min-release-age 7 set on October 15, 2026, poetry add boto3 locked 1.43.110 from October 8 instead of 1.43.114 from October 14 (Poetry docs). That is a working dependency cooldown, but it fails open: a private index that omits upload times gets no protection, while uv treats such files as unavailable. Any cooldown also delays security fixes by the same seven days, so list packages you must patch quickly in solver.min-release-age-exclude.

Publishing is the starkest difference. Poetry 2.5.1's upload code authenticates only with a username and password or a long-lived API token, and its source contains no OIDC path for trusted publishing. Poetry projects can still publish without a stored token by building with poetry build and uploading with PyPA's pypi-publish action (PyPI docs).

Our verdict: start new projects that mix internal and public indexes on uv, because its defaults fail closed where Poetry's fail open. Keep an existing Poetry project if you apply the configuration below and move uploads to the PyPA action. Trusted publishing, in either tool, proves where a release was built, not that its code is safe, since a stolen maintainer session or CI token publishes through the same pipeline.

A hardened Poetry configuration

#

These settings cover the resolver and installer gaps we tested. They go in a committed poetry.toml, written with poetry config --local:

Plain text
# poetry.toml
[solver]
min-release-age = 7

[installer]
only-binary = [":all:"]
no-binary = ["fastparse-utils"]  # exceptions that must build from source

Add the explicit internal source and per-package pins from the source-priority section to pyproject.toml, poetry.lock -linguist-generated to .gitattributes, and a CI job that runs poetry lock without credentials and fails on a diff. None of this stops a malicious release that stays undetected for longer than the seven-day floor, or a lock-time build on a machine that holds credentials. Those cases need analysis of what each package contains before it is resolved, not more resolver settings.

Poetry's riskiest commands are the ones developers treat as bookkeeping. poetry add and poetry update resolve against live indexes, run build code, and rewrite a file that code review hides. The fix belongs upstream: a lock-time no-build switch, a first-index default for primary sources, and native trusted publishing would make most of this page unnecessary.