poetry — Dependency Management & Packaging#
What it is#
Poetry manages Python project dependencies through pyproject.toml, creates and manages virtual environments automatically, and provides a workflow for building and publishing packages to PyPI. It replaced setup.py/requirements.txt/virtualenv combinations for many teams.
[!NOTE] uv offers a faster alternative for most poetry workflows in 2026. Poetry is still widely used and is the right choice if you’re already on it or your team prefers its DX.
Install#
# Official installer (recommended — does not pollute your project venv)
curl -sSL https://install.python-poetry.org | python3 -
# Or via pip (simpler, may cause version conflicts)
pip install poetry
Output: (none — exits 0 on success)
Quick example — new project#
poetry new mylib
cd mylib
poetry add requests
poetry run python -c "import requests; print(requests.__version__)"
Output:
Created package mylib at mylib/
Using version ^2.32.3 for requests
Updating dependencies
Resolving dependencies... (0.3s)
Writing lock file
Package operations: 4 installs, 0 updates, 0 removals
• Installing certifi (2024.2.2)
• Installing charset-normalizer (3.3.2)
• Installing idna (3.7)
• Installing requests (2.32.3)
2.32.3
When / why to use it#
- You want a single tool for environment management, dependency resolution, and publishing.
- Your project needs strict lockfiles (for reproducible CI builds).
- You’re publishing a library to PyPI —
poetry buildandpoetry publishmake this simple.
Common pitfalls#
[!WARNING]
poetry addchangespyproject.tomlandpoetry.lock— always commit both files. The lockfile ensures every developer and CI run gets the exact same package versions.
[!WARNING] Poetry creates its own venv — it does not use the
.venvyou created withpython -m venv. Usepoetry env infoto find where Poetry put the venv, or configurepoetry config virtualenvs.in-project trueto place it in.venvat your project root.
[!TIP]
poetry shellactivates the managed venv in a new subshell. Exit withexit. Alternatively, prefix every command withpoetry runto avoid activating.
Richer example — full project workflow#
# Start a new project
poetry new my-api
cd my-api
# Add runtime and dev dependencies
poetry add "fastapi>=0.111" "uvicorn[standard]"
poetry add --group dev "pytest>=8" ruff mypy
# Install all deps (including dev) into the venv
poetry install
# Run tests
poetry run pytest
# Build sdist + wheel
poetry build
Output:
Package operations: 16 installs, 0 updates, 0 removals
• Installing anyio (4.4.0)
• Installing fastapi (0.111.1)
• Installing uvicorn (0.30.1)
...
============================= test session starts ==============================
collected 0 items
============================== no tests ran in 0.05s ==============================
Building my-api (0.1.0)
- Building sdist
- Built my-api-0.1.0.tar.gz
- Building wheel
- Built my_api-0.1.0-py3-none-any.whl
pyproject.toml structure#
[tool.poetry]
name = "my-api"
version = "0.1.0"
description = "A sample API"
authors = ["Alice Dev <alice@example.com>"]
readme = "README.md"
[tool.poetry.dependencies]
python = "^3.12"
fastapi = ">=0.111"
uvicorn = {extras = ["standard"], version = ">=0.30"}
[tool.poetry.group.dev.dependencies]
pytest = ">=8"
ruff = "*"
mypy = "*"
[build-system]
requires = ["poetry-core"]
build-backend = "poetry.core.masonry.api"
Essential commands#
| Command | Purpose |
|---|---|
poetry new <name> | Scaffold a new project |
poetry init | Interactively create pyproject.toml in existing directory |
poetry add <pkg> | Add a dependency |
poetry add --group dev <pkg> | Add a dev dependency |
poetry remove <pkg> | Remove a dependency |
poetry install | Install all deps from lockfile |
poetry install --only main | Install only runtime deps (for prod Docker) |
poetry update | Upgrade all deps within constraints |
poetry show | List installed packages |
poetry run <cmd> | Run command in the venv |
poetry shell | Spawn a shell with venv activated |
poetry build | Build sdist and wheel |
poetry publish | Publish to PyPI (requires API token) |
poetry env info | Show venv location and Python version |
poetry config virtualenvs.in-project true | Place venv in .venv/ |
pyproject.toml schema for Poetry#
Poetry stores everything in pyproject.toml under [tool.poetry] (its legacy layout) or directly in [project] (since Poetry 2.0, which adopted PEP 621). Most existing projects still use [tool.poetry] — both forms are supported.
Legacy layout — [tool.poetry]#
[tool.poetry]
name = "my-api"
version = "0.1.0"
description = "A sample API"
authors = ["Alice Dev <alice@example.com>"]
license = "MIT"
readme = "README.md"
homepage = "https://example.com"
repository = "https://github.com/alicedev/my-api"
documentation = "https://example.com/docs"
keywords = ["api", "fastapi", "sample"]
classifiers = [
"Development Status :: 4 - Beta",
"Programming Language :: Python :: 3.12",
"Topic :: Internet :: WWW/HTTP :: HTTP Servers",
]
packages = [{ include = "my_api", from = "src" }]
include = ["CHANGELOG.md"]
exclude = ["tests/"]
[tool.poetry.dependencies]
python = "^3.12"
fastapi = ">=0.111"
uvicorn = { extras = ["standard"], version = ">=0.30" }
httpx = { version = ">=0.27", optional = true }
[tool.poetry.group.dev.dependencies]
pytest = ">=8"
ruff = "*"
mypy = "*"
[tool.poetry.group.docs.dependencies]
mkdocs = ">=1.6"
mkdocs-material = "*"
[tool.poetry.extras]
http2 = ["httpx"]
[tool.poetry.scripts]
my-api = "my_api.cli:main"
[tool.poetry.urls]
"Bug Tracker" = "https://github.com/alicedev/my-api/issues"
[build-system]
requires = ["poetry-core>=1.9"]
build-backend = "poetry.core.masonry.api"
PEP 621 layout (Poetry 2.0+)#
[project]
name = "my-api"
version = "0.1.0"
description = "A sample API"
authors = [{ name = "Alice Dev", email = "alice@example.com" }]
license = { text = "MIT" }
readme = "README.md"
requires-python = ">=3.12"
dependencies = [
"fastapi>=0.111",
"uvicorn[standard]>=0.30",
]
[project.optional-dependencies]
http2 = ["httpx>=0.27"]
[project.scripts]
my-api = "my_api.cli:main"
[tool.poetry]
packages = [{ include = "my_api", from = "src" }]
[tool.poetry.group.dev.dependencies]
pytest = ">=8"
ruff = "*"
[build-system]
requires = ["poetry-core>=2.0"]
build-backend = "poetry.core.masonry.api"
PEP 621 is the future-proof choice — it makes the project portable to other build backends (hatchling, setuptools) without rewriting metadata.
Version constraints#
Poetry supports a richer set of constraint syntaxes than pip, including caret (^) and tilde (~) ranges familiar from npm. Understanding these is essential because poetry add chooses one for you.
| Syntax | Meaning | Example resolves to |
|---|---|---|
^1.2.3 | Allow non-breaking updates (same major) | >=1.2.3, <2.0.0 |
^0.2.3 | Same minor (0.x is special) | >=0.2.3, <0.3.0 |
^0.0.3 | Same patch (0.0.x is special) | >=0.0.3, <0.0.4 |
~1.2.3 | Allow patch updates only | >=1.2.3, <1.3.0 |
~1.2 | Same minor | >=1.2, <1.3 |
1.2.* | Wildcard | >=1.2.0, <1.3.0 |
>=1.2, <2.0 | Explicit range | as written |
1.2.3 | Exact pin | ==1.2.3 |
* | Any version | latest |
poetry add "fastapi@^0.111" # caret — most common, what `poetry add` defaults to
poetry add "fastapi@~0.111" # tilde — patch-level only
poetry add "fastapi@>=0.111,<0.120" # explicit range
poetry add "fastapi@*" # any version
poetry add "fastapi@latest" # explicit latest
poetry add "fastapi==0.111.1" # exact pin
Output: (none — exits 0 on success)
Dependency groups#
Dependency groups partition pyproject.toml dependencies into named buckets — main, dev, docs, test, anything you want. Groups can be optional (skipped by default) and installed selectively.
[tool.poetry.dependencies]
python = "^3.12"
fastapi = ">=0.111"
[tool.poetry.group.dev.dependencies]
pytest = ">=8"
ruff = "*"
[tool.poetry.group.docs]
optional = true
[tool.poetry.group.docs.dependencies]
mkdocs = ">=1.6"
[tool.poetry.group.benchmark]
optional = true
[tool.poetry.group.benchmark.dependencies]
pytest-benchmark = "*"
Install behavior:
poetry install # main + dev (non-optional groups)
poetry install --without dev # exclude dev
poetry install --with docs # include optional docs
poetry install --with docs,benchmark # multiple optional groups
poetry install --only main # production install — only runtime deps
poetry install --only docs # just one group
poetry install --sync # remove anything not in the lockfile
Output: (none — exits 0 on success)
The --only main install is the right choice for production Docker images — it leaves out pytest, ruff, mypy, mkdocs, and other dev-only weight.
Lockfile behavior — poetry.lock#
poetry.lock records the exact resolved version of every direct and transitive dependency, plus SHA-256 hashes for each downloaded artifact. It is generated automatically by poetry add/update/lock and read by poetry install.
poetry lock # regenerate poetry.lock without installing
poetry lock --no-update # refresh hashes without changing versions
poetry lock --check # verify lock matches pyproject.toml (CI)
Output:
Updating dependencies
Resolving dependencies... (1.8s)
Writing lock file
Snippet:
[[package]]
name = "fastapi"
version = "0.111.1"
description = "FastAPI framework, high performance, easy to learn, fast to code, ready for production"
optional = false
python-versions = ">=3.8"
files = [
{ file = "fastapi-0.111.1-py3-none-any.whl", hash = "sha256:..." },
{ file = "fastapi-0.111.1.tar.gz", hash = "sha256:..." },
]
[package.dependencies]
pydantic = ">=2"
starlette = ">=0.37,<0.38"
[metadata]
lock-version = "2.0"
python-versions = "^3.12"
content-hash = "abc123..."
Properties:
- Always commit
poetry.lockto version control — it’s the source of truth forpoetry install. - Hash-checked: every install verifies SHA-256 against the lock.
- Single platform per lock: unlike
uv.lock,poetry.lockis not platform-universal. Developers on macOS may need a Linuxpoetry.lockfor CI — typically resolved by locking inside a Linux container. content-hashrecords the resolved state ofpyproject.toml. If you edit constraints by hand,poetry installwarns that the lock is out of date.
poetry add / remove / update / install#
# add — installs and records in pyproject.toml
poetry add requests
poetry add "fastapi>=0.111"
poetry add "uvicorn[standard]" # extras
poetry add --group dev pytest mypy ruff # dev group
poetry add --group docs mkdocs # named group
poetry add --optional httpx # optional dependency
poetry add --editable ./libs/mylib # editable install
poetry add git+https://github.com/owner/repo
poetry add git+https://github.com/owner/repo#main
poetry add git+https://github.com/owner/repo#v1.0.0
poetry add ./local-package # local path
poetry add --source private my-internal-lib
# remove — opposite of add
poetry remove requests
poetry remove --group dev pytest
# update — refresh dependencies within constraints
poetry update # update all
poetry update fastapi httpx # update specific
poetry update --dry-run # preview without installing
poetry update --lock # only update poetry.lock, don't install
# install — install from poetry.lock
poetry install # main + dev groups
poetry install --no-root # skip installing the project itself
poetry install --sync # remove packages not in lock
poetry install --only main # production install
poetry install --with docs # include optional group
Output: (none — exits 0 on success)
Configuration#
poetry config controls behavior globally (user config) or per-project (local config). Settings persist across runs.
poetry config --list # show all settings
poetry config virtualenvs.in-project true # .venv in project root (recommended)
poetry config virtualenvs.create false # use external venv (CI/Docker)
poetry config virtualenvs.path ~/.poetry-venvs # custom venv parent
poetry config virtualenvs.prefer-active-python true # use the currently-active Python
poetry config installer.parallel true # parallel installs (default true)
poetry config installer.max-workers 10 # cap concurrency
poetry config cache-dir ~/.cache/pypoetry # cache location
poetry config repositories.private https://pypi.mycompany.com/simple/
poetry config http-basic.private alice "$PRIVATE_PYPI_TOKEN"
poetry config pypi-token.pypi "pypi-AgEIcHl..." # PyPI upload token
# Local (per-project) — writes to poetry.toml
poetry config --local virtualenvs.in-project true
Output:
cache-dir = "/home/alice/.cache/pypoetry"
installer.max-workers = 10
installer.parallel = true
virtualenvs.create = false
virtualenvs.in-project = true
virtualenvs.path = "/home/alice/.poetry-venvs"
virtualenvs.prefer-active-python = true
Result of poetry config --local:
# poetry.toml (committed to git)
[virtualenvs]
in-project = true
virtualenvs.in-project = true is the most impactful setting — it puts the venv at ./.venv instead of a hidden cache directory, so IDEs and tooling find it automatically.
Building and publishing#
poetry build invokes the project’s build backend (defaults to poetry-core) and produces a source distribution and wheel under dist/. poetry publish uploads to PyPI (or a configured private index).
poetry build # both sdist + wheel
poetry build -f sdist # source dist only
poetry build -f wheel # wheel only
poetry publish # upload dist/* to PyPI
poetry publish --build # build then publish in one step
poetry publish --repository testpypi # upload to TestPyPI
poetry publish --username __token__ --password "pypi-..."
poetry publish --skip-existing # don't fail if version already on PyPI
# Configure once, never type the token again
poetry config pypi-token.pypi "pypi-AgEIcHl..."
poetry config repositories.testpypi https://test.pypi.org/legacy/
poetry config pypi-token.testpypi "pypi-..."
Output of poetry build:
Building my-api (0.1.0)
- Building sdist
- Built my_api-0.1.0.tar.gz
- Building wheel
- Built my_api-0.1.0-py3-none-any.whl
Output of poetry publish:
Publishing my-api (0.1.0) to PyPI
- Uploading my_api-0.1.0.tar.gz 100%
- Uploading my_api-0.1.0-py3-none-any.whl 100%
[!TIP] Use Trusted Publishing on PyPI (OIDC) to avoid storing tokens. Configure it once in PyPI’s UI and your GitHub Actions workflow can upload without
pypi-token.
Virtual environment management#
Poetry creates a venv per project automatically. Inspect, switch, or remove them with poetry env.
poetry env info # show active venv path, Python version, system info
poetry env list # list venvs for this project
poetry env list --full-path # show full paths
poetry env use 3.12 # switch project to Python 3.12 (creates new venv)
poetry env use /usr/bin/python3.11 # use a specific interpreter
poetry env remove python3.10 # delete a venv
poetry env remove --all # nuke all venvs for this project
Output of poetry env info:
Virtualenv
Python: 3.12.4
Implementation: CPython
Path: /home/alice/.cache/pypoetry/virtualenvs/my-api-AbCdEfGh-py3.12
Executable: /home/alice/.cache/pypoetry/virtualenvs/my-api-AbCdEfGh-py3.12/bin/python
Valid: True
Base
Platform: linux
OS: posix
Python: 3.12.4
Path: /usr
Executable: /usr/bin/python3.12
poetry shell was removed in Poetry 2.0 — use poetry env activate (prints the command to run) or just prefix everything with poetry run.
poetry run and poetry shell#
poetry run python script.py
poetry run pytest -v
poetry run uvicorn main:app --reload
# Spawn a subshell with the venv activated (Poetry < 2.0)
poetry shell
# Poetry 2.0+ — prints the activation command
eval "$(poetry env activate)"
Output:
============================= test session starts ==============================
collected 24 items
tests/test_app.py ........................ [100%]
============================== 24 passed in 0.84s ==============================
INFO: Uvicorn running on http://127.0.0.1:8000 (Press CTRL+C to quit)
poetry run is the recommended day-to-day pattern — no subshells, no stale activations, and it works the same in scripts as it does interactively.
Plugin ecosystem#
Poetry has a stable plugin API since 1.2. Plugins extend the CLI with new commands or hook into existing ones. Install with poetry self add.
| Plugin | Purpose |
|---|---|
poetry-plugin-export | Export poetry.lock to requirements.txt (bundled since 1.5) |
poetry-dynamic-versioning | Compute version from git tags |
poetry-plugin-bundle | Bundle the app + venv into a tarball |
poetry-plugin-shell | Restore the poetry shell command (removed in 2.0) |
poetry-plugin-up | Upgrade dependencies past the existing constraints |
poetry-plugin-mono-repo-deps | Local-path resolution for monorepos |
poethepoet | Task runner that reads [tool.poe.tasks] |
poetry self add poetry-plugin-export
poetry self add poetry-dynamic-versioning[plugin]
poetry self show plugins
poetry self remove poetry-plugin-export
Output:
Using version ^1.8.0 for poetry-plugin-export
poetry-plugin-export (1.8.0)
poetry-dynamic-versioning (1.4.0)
poetry export is the most-used plugin in practice — it converts poetry.lock to a requirements.txt for tools that don’t speak Poetry:
poetry export -f requirements.txt --output requirements.txt --without-hashes
poetry export -f requirements.txt --only main --output prod-requirements.txt
poetry export -f constraints.txt --output constraints.txt
Output: (none — exits 0 on success)
Comparison — Poetry vs uv vs hatch vs pdm vs rye#
| Feature | Poetry | uv | hatch | pdm | rye |
|---|---|---|---|---|---|
| Language | Python | Rust | Python | Python | Rust |
| Lockfile | poetry.lock | uv.lock (universal) | none (uses pip) | pdm.lock | requirements.lock |
| Speed | Slow | Fastest | Fast | Moderate | Fast |
| Python download | No | Yes | Yes | Yes | Yes |
| PEP 621 native | 2.0+ | Yes | Yes | Yes | Yes |
| Build backend | poetry-core | Any PEP 517 | hatchling | pdm-backend | Any |
| Workspaces | Yes | Yes | Yes (matrix) | Yes | Yes |
| Plugin ecosystem | Large | Small | Moderate | Moderate | Small |
| Drop-in pip surface | No | uv pip | No | pdm (limited) | No |
| Tool installer | No | uv tool (pipx-like) | No | No | rye tools |
| Best for | Existing Poetry teams | New projects, speed | Library authors | Standards purists | (Now subsumed by uv) |
Recommendation in 2026: new projects should start with uv unless the team has strong Poetry preferences. Migrating from Poetry to uv is a one-day job for most projects (export requirements.txt, run uv init and uv add, copy [tool.*] config).
Common pitfalls (additional)#
[!WARNING]
poetry installdoes not install your project by default in--no-rootmode — and the default behavior installs your project as editable into the venv. This can be confusing if you don’t expect your own package to be importable.
[!WARNING] Lockfile drift on team merges — when two PRs both run
poetry add, the resulting lockfile merge conflict is messy. Resolve by accepting one side and re-runningpoetry lock --no-updateto recompute hashes.
[!WARNING]
poetry installon a project with--only mainstill creates a venv — for true production Docker builds, also setvirtualenvs.create = falseand install into the system Python of a slim image.
[!WARNING] Caret ranges and 0.x versions are surprising —
^0.2.3resolves to>=0.2.3, <0.3.0, not>=0.2.3, <1.0.0. Many libraries break this convention; pin tightly if a dependency is unstable.
[!TIP] Always
poetry config --local virtualenvs.in-project truebefore the firstpoetry install. It puts.venvnext topyproject.tomlso VS Code, PyCharm, and direnv find it automatically.
[!TIP] In CI, use
poetry install --sync --no-interaction --no-ansi --only mainfor production builds.--syncremoves packages that aren’t in the lock;--no-ansikeeps logs clean.
[!TIP] If
poetry addis slow, setinstaller.parallel = true(default) and pre-populate the cache by runningpoetry installonce on a base CI image.
Real-world recipes#
Recipe — start a publishable library#
poetry new --src mylib
cd mylib
poetry add --group dev pytest ruff mypy
poetry version 0.1.0
poetry build
poetry config pypi-token.pypi "pypi-AgEIcHl..."
poetry publish
Output:
Created package mylib in mylib
Building mylib (0.1.0)
- Building sdist
- Built mylib-0.1.0.tar.gz
- Building wheel
- Built mylib-0.1.0-py3-none-any.whl
Publishing mylib (0.1.0) to PyPI
- Uploading mylib-0.1.0-py3-none-any.whl 100%
- Uploading mylib-0.1.0.tar.gz 100%
Recipe — production-ready FastAPI Docker image#
FROM python:3.12-slim AS builder
ENV POETRY_VERSION=1.8.3 \
POETRY_HOME=/opt/poetry \
POETRY_VIRTUALENVS_CREATE=false \
POETRY_NO_INTERACTION=1
RUN pip install --no-cache-dir "poetry==${POETRY_VERSION}"
WORKDIR /app
COPY pyproject.toml poetry.lock ./
RUN poetry install --only main --no-root --no-directory
FROM python:3.12-slim
COPY --from=builder /usr/local/lib/python3.12/site-packages /usr/local/lib/python3.12/site-packages
COPY --from=builder /usr/local/bin /usr/local/bin
COPY . /app
WORKDIR /app
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
POETRY_VIRTUALENVS_CREATE=false installs into the system Python; --no-root --no-directory skips installing your project so you can cache the dependency layer separately.
Recipe — GitHub Actions for Poetry#
name: CI
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with: { python-version: "3.12" }
- name: Install Poetry
run: pipx install poetry==1.8.3
- name: Cache venv
uses: actions/cache@v4
with:
path: .venv
key: venv-${{ runner.os }}-${{ hashFiles('poetry.lock') }}
- run: poetry config virtualenvs.in-project true
- run: poetry install --sync
- run: poetry run pytest
- run: poetry run ruff check .
- run: poetry run mypy src
Recipe — migrate from Poetry to uv#
# Export Poetry's dependencies as a requirements.txt
poetry export -f requirements.txt --without-hashes --output requirements.txt
poetry export -f requirements.txt --only dev --without-hashes --output requirements-dev.txt
# Initialize uv in the same project
uv init --no-readme
uv add -r requirements.txt
uv add --dev -r requirements-dev.txt
uv sync
# Clean up after verifying
rm requirements.txt requirements-dev.txt poetry.lock
# Edit pyproject.toml to remove [tool.poetry.*] sections
Output: (none — exits 0 on success)
Recipe — private PyPI#
# Configure a private index
poetry config repositories.private https://pypi.mycompany.com/simple/
poetry config http-basic.private alice "$PRIVATE_PYPI_TOKEN"
# Add a package from the private index
poetry add --source private my-internal-tool
# pyproject.toml now contains:
# [[tool.poetry.source]]
# name = "private"
# url = "https://pypi.mycompany.com/simple/"
# priority = "supplemental"
Output: (none — exits 0 on success)
Recipe — task runner with poe#
poetry self add poethepoet
Output: (none — exits 0 on success)
[tool.poe.tasks]
test = "pytest -v"
lint = "ruff check ."
format = "ruff format ."
typecheck = "mypy src"
check = ["lint", "typecheck", "test"]
serve = "uvicorn main:app --reload"
poetry run poe check # runs lint, typecheck, test in order
poetry run poe serve
Output:
Poe => ruff check .
All checks passed!
Poe => mypy src
Success: no issues found in 12 source files
Poe => pytest -v
============================== 24 passed in 0.91s ==============================
INFO: Uvicorn running on http://127.0.0.1:8000 (Press CTRL+C to quit)
Environment variables#
| Variable | Purpose |
|---|---|
POETRY_VIRTUALENVS_IN_PROJECT | true → place venv at ./.venv |
POETRY_VIRTUALENVS_CREATE | false → use ambient Python (Docker, CI) |
POETRY_VIRTUALENVS_PATH | Custom parent dir for venvs |
POETRY_NO_INTERACTION | Disable all prompts (1) |
POETRY_CACHE_DIR | Custom cache directory |
POETRY_HTTP_TIMEOUT | HTTP request timeout in seconds |
POETRY_INSTALLER_PARALLEL | Toggle parallel installs |
POETRY_INSTALLER_MAX_WORKERS | Concurrency cap |
POETRY_PYPI_TOKEN_PYPI | PyPI upload token |
POETRY_HTTP_BASIC_<REPO>_USERNAME | Basic auth username for <REPO> |
POETRY_HTTP_BASIC_<REPO>_PASSWORD | Basic auth password / token |
POETRY_REQUESTS_CA_BUNDLE | Custom CA bundle for self-signed corporate certs |