Pinning ansible-core with pip-tools, uv and Poetry
Second entry in the Ansible development environment series; the first ranks all the options. pip-tools, uv and Poetry all do the same job for an Ansible project. They record the exact ansible-core a project runs, plus everything it pulls in, so every laptop and CI job installs the same thing. They differ in file format and in the version syntax they accept. Everything below was run on 2026-09-29 with ansible-core 2.21.4, pip-tools 7.6.1, uv 0.12.20 and Poetry 2.5.1.
The vocabulary:
- ansible-core is the engine: ansible-playbook and the ansible.builtin modules. The ansible package on PyPI is ansible-core plus more than 85 collections, the bundles that ship extra modules. This entry pins ansible-core.
- ansible-runner is a Python library and command for running playbooks from other programs; AWX uses it.
- A transitive dependency is one pulled in by your dependencies. ansible-core brings Jinja2, PyYAML, cryptography and a few more.
- A lock file records the exact version of every package, direct and transitive. The file you edit holds constraints, the versions you accept; the lock holds the versions you get.
- The control node is the machine that runs ansible-playbook. Its Python is what these tools manage, each in a venv, a directory with its own interpreter and packages.
ansible-core versions aren't semantic versioning
Semantic versioning reads MAJOR.MINOR.PATCH and allows breaking changes only when MAJOR changes. ansible-core's release and maintenance page says it "does not use semantic versioning". Its major version is the second number:
Approximately every 6 months, in May and November ansible-core releases a new Major release. This is denoted by the
Yin theX.Y.Zversion scheme. […]X.Y.0releases do not carry any guarantee of 100% backwards compatibility with the version before it.
So 2.20 → 2.21 is a major upgrade, and 2.21.3 → 2.21.4 is a patch. Each major release also supports its own range of control-node Python versions:
| ansible-core | Released | End of life | Control-node Python |
|---|---|---|---|
| 2.15 | May 2023 | Nov 2024 | 3.9 – 3.11 |
| 2.19 | Jul 2025 | Nov 2026 | 3.11 – 3.13 |
| 2.20 | Nov 2025 | May 2027 | 3.12 – 3.14 |
| 2.21 | May 2026 | Nov 2027 | 3.12 – 3.14 |
On PyPI, ansible-core 2.21 declares only the lower bound, >=3.12. Both facts matter below: Poetry's ^ treats all of 2.x as one release line, and a project whose Python range includes 3.11 can't install 2.21.
pip-tools: .in → .txt
pip-tools compiles a short .in file of top-level requirements into a requirements.txt with every package pinned.
# requirements/base.in
ansible-core~=2.21.0
ansible-runner>=2.3.0,<3.0.0
pip-compile requirements/base.in # writes requirements/base.txt
pip-sync requirements/base.txt # makes the venv match it exactly
ansible-core==2.21.4
# via -r requirements/base.in
ansible-runner==2.4.3
# via -r requirements/base.in
cryptography==50.0.1
# via ansible-core
jinja2==3.1.6
# via ansible-core
...
The result is only valid for the Python that pip-compile ran under; its header says so: autogenerated by pip-compile with Python 3.13. Run under Python 3.11, the same .in fails with DistributionNotFound. Development tools go in a second file, constrained by the first so they can't move ansible-core:
# requirements/dev.in
-c base.txt
ansible-lint>=26.0
molecule>=26.0
uv pip compile requirements/base.in -o requirements/base.txt is a faster drop-in for pip-compile.
uv: pyproject.toml → uv.lock
pyproject.toml is the standard Python project file, and its [project] table lists dependencies. uv writes the lock file uv.lock. A minimal [project] with only requires-python and dependencies fails twice:
`pyproject.toml` is using the `[project]` table, but the required `project.name` field is not set
With name and version added, and requires-python = ">=3.11,<3.13":
error: No solution found when resolving dependencies for split (markers: python_full_version == '3.11.*')
cause: Because the requested Python version (>=3.11, <3.13) does not satisfy Python>=3.12 and ansible-core==2.21.4 depends on Python>=3.12 [...]
uv.lock is universal: it resolves for every Python the project allows, not just the one running, so the range must start where ansible-core's does. A working file:
[project]
name = "ansible-env"
version = "0.1.0"
requires-python = ">=3.12,<3.15"
dependencies = [
"ansible-core~=2.21.0",
"ansible-runner>=2.3.0,<3.0.0",
]
[dependency-groups]
dev = ["ansible-lint>=26.0", "molecule>=26.0"]
uv lock # writes uv.lock
uv sync # creates .venv and installs the lock, dev group included
uv sync --locked # in CI: fails if uv.lock doesn't match pyproject.toml
uv sync --no-dev # without ansible-lint and molecule
Poetry: pyproject.toml → poetry.lock
Poetry 2 refuses a [tool.poetry.dependencies] table on its own:
The Poetry configuration is invalid:
- Either [project.name] or [tool.poetry.name] is required in package mode.
A project that only needs an environment, not a package to publish, says package-mode = false. Poetry's lock is universal too, so python = "^3.11" fails the same way uv did: ansible-core requires Python >=3.12, so it will not be installable for Python >=3.11,<3.12.
[tool.poetry]
package-mode = false
[tool.poetry.dependencies]
python = ">=3.12,<3.15"
ansible-core = "~2.21.0"
ansible-runner = "^2.3.0"
[tool.poetry.group.dev.dependencies]
ansible-lint = ">=26.0"
molecule = ">=26.0"
poetry env use python3.13 # if Poetry runs on an older Python, pick one in the range
poetry lock # writes poetry.lock
poetry sync # installs exactly the lock, removes anything else
poetry sync --without dev
poetry check --lock # in CI: fails if poetry.lock doesn't match pyproject.toml
Molecule and ansible-lint switched to year-based versions: 24.x in 2024, 26.9.0 in September 2026. A molecule = "^5.0.0" constraint keeps you on a 2023 release.
Constraint syntax
The Python standard for version constraints is PEP 440; pip-tools and uv accept only that. Poetry accepts PEP 440 plus its own ^ and ~. Checked by resolving each against PyPI:
| Constraint | pip-tools / uv | Poetry | Allows for ansible-core |
|---|---|---|---|
==2.21.4 |
✅ | ✅ | 2.21.4 only |
>=2.21.0,<2.22 |
✅ | ✅ | 2.21.x |
~=2.21.0 |
✅ | ✅ | 2.21.x: PEP 440 "compatible release", >=2.21.0, ==2.21.* |
~2.21.0 |
❌ parse error | ✅ | 2.21.x |
^2.21.0 |
❌ parse error | ✅ | up to 3.0: 2.22, 2.23, … |
~=2.21 |
✅ | ✅ | up to 3.0: two parts means >=2.21, ==2.* |
The last two are the traps. Poetry's caret assumes semantic versioning, so for ansible-core it allows every future major release. ansible-core = "^2.15.9" locked 2.21.4, six major releases later, needing a newer Python than 2.15 did. ansible-core~=2.15 also resolved to 2.21.4. ~=2.15.9, ~2.15.9 and >=2.15.0,<2.16.0 all stayed on 2.15.13.
The ansible package does it the right way: ansible 14.4.0 requires ansible-core~=2.21.4.
What to put in the file
With a lock file, the exact version is already pinned. The constraint only decides what an update may pick. So write the range you'd accept without reading a porting guide:
ansible-core~=2.21.0 # or >=2.21.0,<2.22; in Poetry, "~2.21.0" works too
Patch releases are then one command away. Starting from a lock at 2.21.0, each of these moved ansible-core to 2.21.4 and left the rest of the lock alone:
| Tool | Command |
|---|---|
| pip-tools | pip-compile --upgrade-package ansible-core requirements/base.in |
| uv | uv lock --upgrade-package ansible-core |
| Poetry | poetry update ansible-core |
Relaxing ==2.21.0 to ~=2.21.0 and re-locking without those commands kept 2.21.0, in all three tools. Going to 2.22 stays a deliberate edit: read the porting guide, change the constraint, re-lock. Avoid ranges that span major releases, such as >=2.14.0,<2.17.0. It looks conservative, but it's the loosest line in the file: three majors, each with its own Python range. Start from the version your production control nodes run. If that's 2.15, it has been end of life since November 2024.
Collections aren't in any of these lock files
Collections come from ansible-galaxy, not pip, and are pinned in their own file:
# collections/requirements.yml
collections:
- name: community.general
version: ">=13.0.0,<14.0.0"
ansible-galaxy collection install -r collections/requirements.yml
This installed community.general 13.4.0. Collections follow semantic versioning, so a range below the next major is safe here. ansible-galaxy knows ==, !=, >=, >, <=, < and *; ~=13.0 failed with Non integer values in LooseVersion ('~=13.0'). There is no lock file: community.general's own dependency, community.library_inventory_filtering_v1, was installed at whatever version was newest. For fully fixed collections without extra tooling, pin the ansible package instead of ansible-core. Each ansible release ships a fixed set of collection versions.
Sources
- Ansible docs: release and maintenance (versioning, support table) and installing collections (range identifiers), from ansible/ansible-documentation.
- pip-tools docs.
- uv docs: locking and syncing, dependency groups.
- Poetry docs: dependency specification (
^,~), basic usage (package-mode). - PEP 440 version specifiers (
~=). - Versions and
requires_pythonvalues are from PyPI's JSON API. Every command, error and resolved version above was produced with pip-tools 7.6.1, uv 0.12.20 and Poetry 2.5.1, on Python 3.12 and 3.13 for the lock files and 3.11 for the failures.
Created 2026-09-29T21:57:13+02:00 · Edit