Skip to main content

Poetry - Dependency Management and Packaging Done Right

Reading time: ~30 minutes | Level: Intermediate → Engineering

Before reading further, consider this scenario:

# Team member A installs dependencies on Monday:
# pip install -r requirements.txt → app starts, all tests pass

# Team member B installs the same file on Friday:
# pip install -r requirements.txt → ImportError on startup

# requirements.txt is identical. Python version is identical.
# The machine is identical. What happened?

What happened is that httpx - a transitive dependency of httpx-client which is listed in requirements.txt - released version 0.26.0 between Monday and Friday. That version dropped support for a parameter your code indirectly relied on. Your requirements.txt listed httpx-client>=2.0 but did not pin httpx itself, because httpx is not a direct dependency. pip resolved it differently on different days.

This is not a hypothetical edge case. It is the single most common cause of "works on my machine" failures in Python projects. Poetry's poetry.lock file solves it by pinning every package in the full dependency graph - direct and transitive - to an exact version and hash.

What You Will Learn

  • What Poetry solves and why it exists alongside pip and pip-tools
  • Installation with pipx and why that matters
  • poetry new vs poetry init: when to use each
  • pyproject.toml structure under Poetry
  • poetry.lock: what it contains, why it is deterministic, when to commit it
  • Every key command with engineering-level explanation
  • Version constraints: ^, ~, ranges - exactly what each allows
  • Dependency groups: separating dev, test, and docs dependencies
  • Poetry virtualenv management
  • CI integration and caching strategies
  • Comparison with pip-tools and PDM

Prerequisites

  • Lesson 02 (pyproject.toml) - Poetry uses pyproject.toml as its configuration file
  • Lesson 02 (pip) - understanding what pip does helps explain what Poetry replaces
  • Lesson 01 (venv) - Poetry manages virtualenvs; you need to understand what a virtualenv is

Part 1 - The Problem Poetry Solves

Python dependency management has four distinct concerns. Before Poetry, each was handled by a separate tool:

ConcernOld toolchainPoetry
Declaring dependenciessetup.py / setup.cfg / pyproject.tomlpyproject.toml [tool.poetry.dependencies]
Pinning exact versionspip freeze > requirements.txtpoetry.lock
Managing virtualenvsvenv, virtualenv manuallybuilt-in, automatic
Building and publishingsetuptools + twine separatelypoetry build + poetry publish

Poetry collapses all four into a single workflow. The key technical insight is that Poetry separates two things that requirements.txt conflates:

  • What your package needs (abstract constraints: requests>=2.28)
  • What is actually installed (concrete pinned versions: requests==2.31.0, urllib3==2.1.0, certifi==2024.2.2, ...)

The pyproject.toml expresses the first. The poetry.lock expresses the second - for every package including all transitive dependencies.

Part 2 - Installation

Why pipx, Not pip

# Correct
pipx install poetry

# Common mistake - do not do this
pip install poetry

pipx installs command-line tools into isolated virtualenvs that are separate from any project environment. If you install Poetry with pip into a project's virtualenv and then activate a different virtualenv, Poetry disappears. If you install it globally with pip, it can conflict with your project's dependencies (Poetry itself has dependencies like requests, cachecontrol, crashtest - these should not pollute your projects).

pipx gives Poetry its own persistent, isolated environment while exposing the poetry command globally on your PATH.

# Install pipx first (if not already installed)
pip install --user pipx
pipx ensurepath

# Then install Poetry
pipx install poetry

# Verify
poetry --version
# Poetry (version 1.8.x)
note

Poetry releases frequently. Pin pipx install poetry==1.8.3 if you need reproducible Poetry itself across machines. Running pipx upgrade poetry updates it independently of any project.

Part 3 - poetry new vs poetry init

poetry new - Start a Fresh Project

poetry new my-project

Creates a complete project scaffold:

my-project/
pyproject.toml ← Poetry-managed config
README.md
my_project/
__init__.py
tests/
__init__.py

Use poetry new when starting a greenfield project that will be a Python package (installable, publishable).

# src layout (recommended for packages)
poetry new --src my-project
# Creates: src/my_project/__init__.py

poetry init - Add Poetry to an Existing Project

cd existing-project/
poetry init

Runs an interactive wizard that generates a pyproject.toml without touching your existing files. Use this when:

  • Converting an existing project from requirements.txt to Poetry
  • Adding Poetry to a project that already has source code
  • Adding Poetry to a project that is not a package (a service or application)

Part 4 - pyproject.toml Under Poetry

[tool.poetry]
name = "my-project"
version = "0.1.0"
description = "A short description of the project"
authors = ["Your Name <[email protected]>"]
readme = "README.md"
license = "MIT"
homepage = "https://github.com/yourname/my-project"
repository = "https://github.com/yourname/my-project"
documentation = "https://my-project.readthedocs.io"
keywords = ["utility", "cli"]
classifiers = [
"Development Status :: 3 - Alpha",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
]
packages = [{include = "my_project", from = "src"}]

[tool.poetry.dependencies]
# Python version constraint
python = "^3.11"

# Direct dependencies
requests = "^2.28"
click = "^8.1"
pydantic = "^2.0"

[tool.poetry.group.dev.dependencies]
# Development-only: not installed in production
pytest = "^8.0"
pytest-cov = "^5.0"
ruff = "^0.4"
mypy = "^1.10"
pre-commit = "^3.7"

[tool.poetry.group.docs.dependencies]
mkdocs = "^1.6"
mkdocs-material = "^9.5"

[build-system]
requires = ["poetry-core"]
build-backend = "poetry.core.masonry.api"

The [tool.poetry] section contains publishing metadata. The [tool.poetry.dependencies] section declares runtime dependencies with version constraints. The [tool.poetry.group.*] sections declare optional dependency groups installed only when requested.

Part 5 - poetry.lock: The Reproducibility Engine

When you run any poetry add or poetry install command, Poetry writes (or updates) poetry.lock. This file contains the exact resolved version of every package - including every transitive dependency - plus the SHA-256 content hash of each downloaded file.

A truncated example:

# poetry.lock (auto-generated - do not edit manually)

[[package]]
name = "certifi"
version = "2024.2.2"
description = "Python package for providing Mozilla's CA Bundle."
optional = false
python-versions = ">=3.6"
files = [
{file = "certifi-2024.2.2-py3-none-any.whl", hash = "sha256:dc105f..."},
{file = "certifi-2024.2.2.tar.gz", hash = "sha256:0569859..."},
]

[[package]]
name = "requests"
version = "2.31.0"
description = "Python HTTP for Humans."
optional = false
python-versions = ">=3.7"
files = [
{file = "requests-2.31.0-py3-none-any.whl", hash = "sha256:58cd2..."},
{file = "requests-2.31.0.tar.gz", hash = "sha256:942c5a..."},
]
[package.dependencies]
certifi = ">=2017.4.17"
urllib3 = ">=1.21.1,<3"

Every poetry install on every machine reads this file and installs precisely these versions with hash verification. No network resolution needed. No version drift.

tip

Commit poetry.lock for applications; do NOT commit it for libraries.

For an application (a web service, a CLI tool, an internal script), commit poetry.lock. Everyone on the team and every CI run installs the exact same dependency graph.

For a library (a package other people will install), do NOT commit poetry.lock. If you publish poetry.lock, users who pip install your-library ignore it - but developers contributing to your library may be confused by a lockfile that locks them into dependencies unnecessarily. Let users' package managers resolve what fits their environment. Document the minimum supported versions in pyproject.toml constraints instead.

Part 6 - The poetry add Workflow

The key difference from pip install: Poetry resolves the full dependency graph before touching pyproject.toml or poetry.lock. If the new package conflicts with an existing dependency, Poetry reports it immediately rather than installing a broken environment.

Part 7 - Key Commands

Adding and Removing Dependencies

# Add a runtime dependency (updates pyproject.toml + poetry.lock + installs)
poetry add requests

# Add with a specific version constraint
poetry add "requests>=2.28,<3.0"
poetry add "requests^2.28"

# Add to a dependency group (dev by default for --group dev)
poetry add --group dev pytest
poetry add --group docs mkdocs

# Add a git dependency (useful for unreleased packages)
poetry add git+https://github.com/org/repo.git

# Add an editable local path dependency
poetry add --editable ./libs/my-shared-lib

# Remove a dependency
poetry remove requests
poetry remove --group dev pytest

Installing

# Install all dependencies (reads poetry.lock if it exists)
poetry install

# Install only the main group - skip dev/docs groups (production install)
poetry install --only main

# Install specific groups
poetry install --with docs,test

# Install without a group
poetry install --without docs

# Do not install the root package itself (useful for services)
poetry install --no-root

# Sync: remove packages not in poetry.lock (strict reproducibility)
poetry install --sync

Updating

# Update ALL dependencies to latest versions allowed by constraints
# WARNING: this can break things - see the danger callout below
poetry update

# Update a single package
poetry update requests

# Update multiple specific packages
poetry update requests click pydantic

# Check what would be updated without doing it
poetry update --dry-run
danger

poetry update without arguments updates ALL dependencies to the latest version allowed by your version constraints. In a large project with many dependencies, this can introduce multiple breaking changes simultaneously. If something breaks after poetry update, git diff poetry.lock shows exactly what changed - but isolating which package caused the breakage requires bisection. Prefer poetry update requests (targeted updates) over poetry update (global updates). Review poetry show --outdated first to understand what will change.

Running Code

# Run a command inside Poetry's managed virtualenv
poetry run python script.py
poetry run pytest
poetry run python -m mypackage

# Activate the virtualenv in the current shell
# (changes your shell's Python to Poetry's managed env)
poetry shell
# Now: python, pip, pytest all refer to Poetry's venv

# Exit the activated shell
exit
# or: deactivate (if poetry shell used the standard venv activation)

Inspecting the Environment

# Show all installed packages
poetry show

# Show dependency tree (why is a package installed?)
poetry show --tree

# Show only top-level dependencies
poetry show --top-level

# Show outdated packages
poetry show --outdated

# Show info about a specific package
poetry show requests

# Show info about Poetry's virtualenv
poetry env info

# List all virtualenvs Poetry manages for this project
poetry env list

# Use a specific Python version for this project's venv
poetry env use python3.12
poetry env use /usr/local/bin/python3.11

# Remove the virtualenv (useful for a clean reinstall)
poetry env remove python3.12

Validation and Locking

# Validate pyproject.toml for syntax and consistency
poetry check

# Verify poetry.lock is consistent with pyproject.toml
# (use in CI to ensure developers didn't forget to commit updated lockfile)
poetry lock --check

# Regenerate poetry.lock without installing
poetry lock

# Regenerate without updating existing pins (only adds new packages)
poetry lock --no-update

Building and Publishing

# Build both sdist (.tar.gz) and wheel (.whl) in dist/
poetry build

# Publish to PyPI (prompts for credentials or uses configured token)
poetry publish

# Publish to TestPyPI
poetry publish --repository testpypi

# Build + publish in one step
poetry publish --build

Part 8 - Version Constraints

Poetry uses a version constraint language. Getting constraints wrong is one of the most consequential mistakes in packaging.

The Caret ^ Constraint (Most Common)

The caret constraint allows changes that do not modify the left-most non-zero digit.

^1.2.3 → >=1.2.3, <2.0.0 (most common: allows minor + patch updates)
^1.2 → >=1.2.0, <2.0.0
^1 → >=1.0.0, <2.0.0
^0.2.3 → >=0.2.3, <0.3.0 (0.x.y: left-most non-zero is minor digit)
^0.0.3 → >=0.0.3, <0.0.4 (0.0.x: left-most non-zero is patch digit)
^0.0 → >=0.0.0, <0.1.0
^0 → >=0.0.0, <1.0.0
warning

^1.2.3 allows 1.9.9 but NOT 2.0.0. This is NOT the same as >=1.2.3. If a library releases a major version (2.0.0) with breaking changes, your constraint correctly excludes it. But ^1.2.3 also allows 1.9.0 - which might add deprecations or behavioral changes. The caret is safe for libraries that follow SemVer correctly, but many libraries do not. Review release notes when running poetry update.

The Tilde ~ Constraint (Conservative)

The tilde constraint is more conservative than caret.

~1.2.3 → >=1.2.3, <1.3.0 (patch updates only)
~1.2 → >=1.2.0, <1.3.0 (patch updates only)
~1 → >=1.0.0, <2.0.0 (minor + patch updates - same as ^1)

Use ~ when you need to accept patch updates (bug fixes) but distrust minor version changes from a specific library.

Constraint Comparison

Explicit Range Constraints

# Explicit range: most readable, most explicit
python = ">=3.11,<3.14"
django = ">=4.2,<5.0"

# Exact pin (rare - only for very unstable libraries)
some-unstable-lib = "==1.4.3"

# Wildcard
requests = "2.*" # any 2.x version

Choosing the Right Constraint

ScenarioConstraintReason
Mature library, follows SemVer^2.28Caret: safe for minor + patch updates
Library with frequent breaking changes~2.28.0Tilde: patch updates only
Your package works with multiple major versions>=2.0,<4.0Explicit range: clear intent
Pinning Python version>=3.11,<4.0Explicit: excludes Python 4.x when it arrives
Critical pinned dependency==1.4.3Exact: use sparingly, only when necessary

Part 9 - Dependency Groups

Dependency groups separate concerns. The standard groups are main (default), dev, test, and docs.

[tool.poetry.dependencies]
# main group: installed by default for all users
python = "^3.11"
requests = "^2.31"
click = "^8.1"

[tool.poetry.group.dev.dependencies]
# Installed by default in development (along with main)
# Skipped with --only main in production
pytest = "^8.0"
pytest-cov = "^5.0"
ruff = "^0.4"
mypy = "^1.10"
pre-commit = "^3.7"

[tool.poetry.group.test.dependencies]
# Explicitly separate test deps from other dev tools
pytest = "^8.0"
pytest-cov = "^5.0"
pytest-mock = "^3.14"
factory-boy = "^3.3"

[tool.poetry.group.docs.dependencies]
mkdocs = "^1.6"
mkdocs-material = "^9.5"
mkdocstrings = {extras = ["python"], version = "^0.25"}

Groups map to installation commands:

# Development (installs main + dev groups, which is the default)
poetry install

# Production (installs main group only)
poetry install --only main

# CI test run (installs main + test, skips docs)
poetry install --with test --without docs

# Docs build (installs main + docs, skips dev)
poetry install --with docs --without dev
note

The dev group is special: Poetry installs it by default alongside main (when you run poetry install locally). All other custom groups (like test, docs) must be explicitly requested with --with group-name unless they are marked optional = false. Use this to your advantage: put tools you need in every development session in dev, and tools only needed in specific contexts (docs builds, integration test runs) in their own groups.

Part 10 - Virtual Environment Management

Poetry creates and manages a virtualenv for each project automatically.

# Where do Poetry virtualenvs live?
poetry env info
# Output includes:
# Virtualenv
# Python: 3.12.3
# Implementation: CPython
# Path: /Users/you/Library/Caches/pypoetry/virtualenvs/my-project-Hs3K8j-py3.12
# Executable: /Users/you/Library/Caches/.../bin/python

# On Linux: ~/.cache/pypoetry/virtualenvs/
# On macOS: ~/Library/Caches/pypoetry/virtualenvs/
# On Windows: C:\Users\you\AppData\Local\pypoetry\Cache\virtualenvs\
note

By default Poetry creates virtualenvs in ~/.cache/pypoetry/virtualenvs/ (platform-specific cache directory). Most IDEs (VS Code, PyCharm) expect the virtualenv to be inside the project directory (.venv/). Configure Poetry to create in-project virtualenvs:

poetry config virtualenvs.in-project true

This creates .venv/ inside your project root. Add .venv/ to .gitignore. VS Code and PyCharm automatically detect and use .venv/ without manual configuration.

This setting is global - it applies to all future projects. Existing projects need their virtualenv recreated: poetry env remove python3.12 && poetry install.

# Switch to a different Python version
poetry env use python3.12

# If the Python binary is not on PATH:
poetry env use /usr/local/bin/python3.12

# List all virtualenvs Poetry manages for this project
poetry env list

# Remove a virtualenv
poetry env remove python3.12

# Remove all virtualenvs for this project
poetry env remove --all

Part 11 - Poetry vs pip-tools vs PDM

All three tools solve the dependency pinning problem. Here is a direct comparison:

Featurepip-toolsPoetryPDM
Lockfilerequirements.txt (pinned)poetry.lockpdm.lock
Transitive dep pinningYesYesYes
Hash verificationYesYesYes
Virtualenv managementNo (separate tool)Yes (built-in)Yes (built-in)
Publishing to PyPINo (separate twine)Yes (built-in)Yes (built-in)
pyproject.toml nativePartialYesYes (PEP 517/621)
Dependency groupsVia multiple filesYes (groups)Yes (groups)
Version constraint syntaxPEP 508Poetry-specificPEP 508
SpeedFastModerateFast (Rust resolver)
Learning curveLowModerateModerate
MaturityMature (2012)Mature (2018)Newer (2020)
Best forSimple projects, existing pip usersFull-featured packaging workflowPEP-standards-first teams

pip-tools is the lowest-friction entry point: it uses standard requirements.in files and generates pinned requirements.txt. Poetry is the most integrated: one tool for everything. PDM is the most standards-aligned: it follows PEP 517, PEP 518, PEP 621 strictly.

For new projects at engineering teams, Poetry or PDM are both strong choices. For teams already using pip, pip-tools is the path of least resistance.

Part 12 - CI Integration

GitLab CI

# .gitlab-ci.yml

variables:
PIP_CACHE_DIR: "$CI_PROJECT_DIR/.cache/pip"
POETRY_CACHE_DIR: "$CI_PROJECT_DIR/.cache/poetry"
POETRY_VIRTUALENVS_IN_PROJECT: "true" # .venv/ inside project for caching

default:
image: python:3.12-slim
before_script:
- pip install poetry --quiet
- poetry install --no-interaction --no-ansi

cache:
key:
files:
- poetry.lock
paths:
- .venv/
- .cache/pip/
- .cache/poetry/

test:
stage: test
script:
- poetry run pytest tests/ --tb=short -q --cov=src --cov-report=xml
coverage: '/TOTAL.+?(\d+%)/'
artifacts:
reports:
coverage_report:
coverage_format: cobertura
path: coverage.xml

lint:
stage: test
script:
- poetry run ruff check src/ tests/
- poetry run ruff format --check src/ tests/
- poetry run mypy src/

publish:
stage: deploy
script:
- poetry config pypi-token.pypi $PYPI_TOKEN
- poetry publish --build
rules:
- if: '$CI_COMMIT_TAG =~ /^v[0-9]+\.[0-9]+\.[0-9]+/'

GitHub Actions

# .github/workflows/ci.yml
name: CI

on: [push, pull_request]

jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.12"

- name: Install Poetry
run: pipx install poetry

- name: Cache virtualenv
uses: actions/cache@v4
with:
path: .venv
key: venv-${{ hashFiles('poetry.lock') }}

- name: Install dependencies
run: poetry install --no-interaction --only main,dev

- name: Run tests
run: poetry run pytest tests/ -q

Key CI Flags

# --no-interaction: never prompt for input (required in CI)
# --no-ansi: disable color codes (cleaner CI logs)
# --only main: production install, skip dev deps

poetry install --no-interaction --no-ansi --only main

The cache key for the virtualenv should be the hash of poetry.lock. When poetry.lock changes (new dependencies added or updated), the cache misses and the environment is rebuilt. When poetry.lock is unchanged, the cached .venv/ is reused \text{---} reducing CI time from 2\text{--}3 minutes (full install) to under 10 seconds (cache hit).

Graded Practice

Level 1 \text{---} Predict the Behavior

Given this pyproject.toml snippet:

[tool.poetry.dependencies]
python = "^3.11"
requests = "^2.28.0"
click = "~8.1.0"
pydantic = ">=2.0,<3.0"

Answer for each constraint:

  1. Which versions of requests are allowed?
  2. Which versions of click are allowed?
  3. Which versions of pydantic are allowed?
  4. If requests==3.0.0 releases, will poetry update requests install it?
  5. If click==8.2.0 releases, will poetry update click install it?
Show Answer
  1. requests: ^2.28.0>=2.28.0, <3.0.0. Any version from 2.28.0 up to (but not including) 3.0.0 is allowed: 2.28.1, 2.29.0, 2.31.0, 2.99.9 all qualify. 3.0.0 does not.

  2. click: ~8.1.0>=8.1.0, <8.2.0. Only patch updates within 8.1.x are allowed: 8.1.0, 8.1.3, 8.1.7. 8.2.0 does not qualify.

  3. pydantic: >=2.0,<3.0 → Any version 2.0.0 through 2.x.y but not 3.0.0. 2.0.0, 2.5.0, 2.99.0 all qualify. 3.0.0 does not.

  4. No. requests==3.0.0 is outside the ^2.28.0 constraint (which is <3.0.0). poetry update requests will install the latest version that satisfies <3.0.0 \text{---} which might be 2.32.0 or whatever is newest \text{---} but will not touch 3.0.0.

  5. No. click==8.2.0 is outside the ~8.1.0 constraint (which is <8.2.0). poetry update click will install the latest 8.1.x patch version. To allow 8.2.0, you would need to change the constraint to ^8.1.0 or >=8.1.0,<9.0.0.

Level 2 \text{---} Debug This Setup

A developer on your team runs poetry install and gets:

SolverProblemError

Because my-project depends on both:
• requests (^2.31.0)
• old-client (^1.0.0) which requires requests (<2.20.0)

there is no solution that satisfies all constraints.

They try poetry install --no-interaction \text{---} same error. They try pip install -r requirements.txt from an old file \text{---} it "works" (no error). They come to you saying Poetry is broken.

  1. Is Poetry broken?
  2. Why did pip install -r requirements.txt succeed?
  3. What are the three ways to resolve this?
  4. Which resolution is correct?
Show Answer
  1. Poetry is not broken. Poetry is correctly detecting a real conflict: your project requires requests>=2.31.0 but old-client requires requests<2.20.0. These ranges do not overlap \text{---} no version of requests satisfies both.

  2. pip succeeded because pip does not resolve the full dependency graph before installing. pip install -r requirements.txt installs packages one by one and does not check whether a later package's requirements conflict with an already-installed package. The result is a broken environment where two packages have incompatible dependencies, but pip reports no error. The bug manifests at runtime \text{---} usually as an ImportError or unexpected behavior.

  3. Three resolution paths:

    • Option A: Remove old-client. Find an actively maintained alternative that supports requests>=2.28. This is almost always the right answer if old-client is truly outdated.
    • Option B: Downgrade your direct requests constraint to something both packages accept: requests = ">=1.0.0,<2.20.0". This is usually wrong \text{---} you are constraining your own app to use an old, potentially insecure version of requests to accommodate a dependency.
    • Option C: Fork old-client and update it to support modern requests. Expensive but correct if you cannot replace it.
  4. Option A is almost always correct. A package that requires requests<2.20.0 was last updated before January 2021. It is not maintained. Depending on it is a security and maintenance risk regardless of the Poetry conflict. The Poetry conflict is a feature: it forced you to notice a problem that pip silently hid.

Level 3 \text{---} Design Challenge

You are setting up Poetry for a team building a Django web application. The project has:

  • Runtime dependencies: Django, DRF, celery, redis-py, boto3
  • Development tools: pytest-django, factory-boy, coverage, ruff, mypy, django-stubs
  • Documentation tooling: Sphinx, sphinx-autodoc-typehints
  • A staging environment where docs tooling must not be installed
  • A CI pipeline that runs: (1) linting, (2) tests with coverage, (3) type checking, (4) docs build

Design the complete pyproject.toml dependency section and the poetry install command for each environment. Include the CI caching strategy and explain why your group structure is correct.

Show Answer
[tool.poetry.dependencies]
python = "^3.12"
Django = "^5.0"
djangorestframework = "^3.15"
celery = {extras = ["redis"], version = "^5.3"}
redis = "^5.0"
boto3 = "^1.34"

[tool.poetry.group.dev.dependencies]
# Tools needed every time a developer works on the project
pytest = "^8.0"
pytest-django = "^4.8"
pytest-cov = "^5.0"
factory-boy = "^3.3"
ruff = "^0.4"
mypy = "^1.10"
django-stubs = {extras = ["compatible-mypy"], version = "^5.0"}
types-redis = "^4.6"
pre-commit = "^3.7"

[tool.poetry.group.docs.dependencies]
# Only for docs build \text{---} explicitly optional
Sphinx = "^7.3"
sphinx-autodoc-typehints = "^2.1"
sphinx-rtd-theme = "^2.0"

Install commands per environment:

# Developer workstation: main + dev (default)
poetry install

# Production/staging server: main only
poetry install --only main

# CI lint job: main + dev (ruff is in dev)
poetry install --only main,dev

# CI test job: main + dev (pytest is in dev)
poetry install --only main,dev

# CI type check job: main + dev (mypy + stubs are in dev)
poetry install --only main,dev

# CI docs build job: main + docs only
poetry install --only main,docs

CI caching strategy:

# .gitlab-ci.yml
variables:
POETRY_VIRTUALENVS_IN_PROJECT: "true"

cache:
key:
files:
- poetry.lock # cache invalidates only when lockfile changes
prefix: "$CI_JOB_NAME" # separate cache per job (lint vs test vs docs)
paths:
- .venv/

The prefix: "$CI_JOB_NAME" is important: the lint job installs main,dev; the docs job installs main,docs. If they shared a cache, the docs job might use a .venv/ from the lint job that is missing Sphinx - or vice versa. Separate caches per job name ensure each job gets the right virtualenv.

Why the group structure is correct:

  1. There is no separate test group - test tools (pytest, pytest-django, factory-boy) belong in dev. Every developer runs tests locally. Separating them into a test group would require every developer to add --with test on every install, which is friction without benefit.

  2. docs is its own group because Sphinx and its plugins are large, slow to install, and not needed on developer workstations or production servers. Only the docs build job needs them. Keeping them separate makes every other poetry install faster.

  3. pre-commit is in dev because it needs to be available locally for developers to run pre-commit install. CI runs pre-commit differently (via the pre-commit cache, not the project venv).

Key Takeaways

  • Poetry solves four concerns in one tool: dependency resolution, lockfile management, virtualenv management, and publishing. Before Poetry, each required a separate tool.
  • Install Poetry with pipx, not pip. pipx isolates Poetry's own dependencies from your project, giving you a stable global poetry command regardless of which virtualenv is active.
  • poetry.lock is the source of truth for what is installed. It pins every package - direct and transitive - to an exact version with SHA-256 hash verification. Every poetry install on every machine installs precisely the same dependency graph.
  • Commit poetry.lock for applications; do not commit it for libraries. Applications need reproducibility across machines and CI runs. Libraries need to let users' package managers resolve the best compatible versions for their environment.
  • Use ^ for most constraints (^1.2.3>=1.2.3, <2.0.0). Use ~ when you want only patch updates (~1.2.3>=1.2.3, <1.3.0). Use explicit ranges (>=1.2,<2.0) when you need maximum clarity. Never use unconstrained * in production.
  • poetry update without arguments is dangerous: it updates all dependencies at once, potentially introducing multiple breaking changes that are hard to isolate. Prefer poetry update requests (targeted) and always review git diff poetry.lock after any update.
  • Dependency groups ([tool.poetry.group.dev], [tool.poetry.group.docs]) separate development tooling from runtime dependencies. Use poetry install --only main in production to avoid installing pytest, mypy, and Sphinx on production servers.
  • poetry env info and poetry env use python3.12 give you precise control over which Python version Poetry's virtualenv uses. Set virtualenvs.in-project = true globally for IDE compatibility.
  • In CI: cache the .venv/ directory keyed on poetry.lock hash. A lockfile cache hit drops install time from 2–3 minutes to under 10 seconds. Use --no-interaction --no-ansi in all CI poetry install calls.
  • poetry lock --check in CI verifies that poetry.lock is up to date with pyproject.toml. If a developer added a dependency to pyproject.toml but forgot to run poetry lock, this command catches it.
© 2026 EngineersOfAI. All rights reserved.