# 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

Source: https://socket.dev/glossary/poetry

Last updated: 2026-10-15

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](https://socket.dev/glossary/python-package-index-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](https://socket.dev/glossary/pip-python-package-index) 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](https://pypi.org/project/poetry/#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](https://socket.dev/glossary/virtual-environment-python) 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](https://github.com/python-poetry/poetry/blob/main/CHANGELOG.md)). 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](https://socket.dev/blog/packaging-trends-in-python-highlights-from-the-2023-developer-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:

```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](https://github.com/python-poetry/poetry/blob/main/CHANGELOG.md)).

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](https://socket.dev/glossary/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](https://socket.dev/blog/python-adopts-standard-lock-file-format-for-reproducible-installs) 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](https://socket.dev/glossary/wheel-python-packaging-format) 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](https://github.com/advisories/GHSA-2599-h6xx-hpxp)).

> Four points in a Poetry workflow where package code can run: resolution, installation, Poetry plugins, and runtime

| Step | Commands | When package code runs | Effect of installer.only-binary |
| --- | --- | --- | --- |
| 1. Resolve | poetry add, poetry lock, poetry update | An sdist without static metadata has its build backend run so Poetry can read its dependencies | None |
| 2. Install | poetry install, poetry sync | Wheels run no code. A locked sdist is built, running its backend, when no locked wheel fits the machine | Blocks the build and fails the install |
| 3. Tooling | Any later poetry command | Project plugins from requires-plugins load inside Poetry's own process | None |
| 4. Runtime | poetry run, python | Importing a package runs its module code, and .pth files run at every interpreter start | None |

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](https://peps.python.org/pep-0643/)) 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:

| Command | Setting | setup.py runs |
| --- | --- | --- |
| poetry lock | default | 2 (egg\_info, then dist\_info) |
| poetry lock | installer.only-binary = :all: | 2 |
| poetry install | installer.only-binary = :all: | 0, and the install fails |
| poetry install | default | 2 (egg\_info, then bdist\_wheel) |
| poetry lock | sdist declares Metadata-Version 2.2 | 0 |
| uv lock | default | 2 |
| uv lock | --no-build | 0, and resolution fails |

`installer.only-binary` protects `poetry install`, not `poetry lock`. Poetry's [configuration docs](https://python-poetry.org/docs/configuration/#installerno-binary) 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](https://socket.dev/blog/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](https://python-poetry.org/docs/plugins/#project-plugins)). We declared a path plugin whose `activate()` method writes a marker file, then ran commands in order:

| Command, in order | Plugin activations so far |
| --- | --- |
| poetry check | 0 |
| poetry show | 0 |
| poetry install | 0, but it prints "Installing Poetry plugins only for the current project..." |
| poetry show | 1 |
| poetry lock | 2 |
| poetry env info -p | 3 |
| poetry --version | 4 |

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](https://python-poetry.org/docs/repositories/#configuring-credentials)). 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](https://python-poetry.org/docs/repositories/#package-source-constraint)).

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](https://socket.dev/glossary/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 configuration | acme-billing locked | acme-core locked |
| --- | --- | --- |
| internal and public both primary, internal listed first | 99.0.0 from public | Not locked, because 99.0.0 declares no dependencies |
| public primary, internal supplemental | 99.0.0 from public | Not locked |
| internal explicit, only acme-billing pinned to it | 1.2.0 from internal | 99.0.0 from public |
| internal explicit, both packages pinned to it | 1.2.0 from internal | 1.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:

```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](https://socket.dev/glossary/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](https://docs.astral.sh/uv/concepts/indexes/)).

## 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:

```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](https://github.com/github-linguist/linguist/blob/main/lib/linguist/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](https://docs.socket.dev/docs/socket-for-github)), and poetry.lock is among its supported inputs ([ecosystem support](https://docs.socket.dev/docs/language-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.

| Control | Poetry 2.5.1 | uv 0.13.1 |
| --- | --- | --- |
| Several indexes | Highest version across all primary sources; safe only with per-package source pins | first-index by default: each package comes from the first index that has it |
| Build code while locking | Runs sdist backends when metadata is missing; no setting stops it | Runs them by default; --no-build refuses |
| Build code while installing | installer.only-binary refuses | --no-build refuses |
| Release-age floor | solver.min-release-age in days, since 2.4.0; a release with no upload time is not filtered | exclude-newer date or duration; a file with no upload time is treated as unavailable |
| Lock | poetry.lock, universal, SHA-256 per file | uv.lock, universal, hash per file |
| Standard pylock.toml export | Through poetry-plugin-export, since 2.3.0 | uv export --format pylock.toml |
| Publishing | Username and password or API token only | Trusted publishing built in |
| Code inside the tool | Project plugins installed from requires-plugins | No 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](https://python-poetry.org/docs/configuration/#solvermin-release-age)). That is a working [dependency cooldown](https://socket.dev/glossary/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](https://socket.dev/glossary/npm-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](https://docs.pypi.org/trusted-publishers/using-a-publisher/)).

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`:

```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.
