Collegica Collegica Collegica
  • Subjects
    • Robotics
    • Software development
    • AI
    • Finance
    • Aging well
  • Events
  • Talks
  • About
  • Newsletter

Environments Are Not Packages

Why pixi and Poetry are not competing tools, what a lockfile can and cannot buy you, and where uv actually lands

Author

Behzad Samadi

Published

September 12, 2026

The question people ask is “pixi or Poetry?” — or lately “why not just use uv for everything?” Both are malformed, in the same way. They assume the tools are competing for one job. They are doing two different jobs, and the distinction between those jobs is older than any of the three tools, older than pyproject.toml, and the single most useful thing to understand about Python packaging.

Here it is in one line:

An environment is a machine state you recreate. A package is an artefact you distribute.

One gets reproduced. The other gets consumed. Almost everything that frustrates people about Python dependency management comes from applying the habits of one to the other.

This article is about the tools and the boundary between them. Its companion, Python for Reproducible Research, is about the wider arc those tools sit inside — getting from a notebook that works on your machine to a repository someone else can clone and run.

NoteVersions, and the date on this page

Checked on 12 September 2026: pixi 0.80.0, Poetry 2.4.3, uv 0.12.13. Two of the three are pre-1.0 and all three move quickly, so treat specific commands as dated and the argument as durable. Where a claim comes from a tool’s own documentation, it is quoted and linked rather than paraphrased.

Two artefacts, two jobs

The clearest statement of this predates pixi, Poetry and uv alike. The Python Packaging Authority has been drawing it for years, in the language of abstract versus concrete dependencies:

  • A library’s declared requirements are “‘Abstract’ requirements, i.e just names and version restrictions that don’t determine where the dependencies will be fulfilled from”. They are minimal by design.
  • A requirements file, by contrast, contains “an exhaustive listing of pinned versions for the purpose of achieving repeatable installations of a complete environment”.

That is the whole distinction, and it generalises past requirements.txt to every lockfile in use today:

A library declares what it can work with. An application records what it did work with.

The first is a constraint on a solution space — it must stay loose, because the person installing your library is also installing forty other things and needs room to find a solution that satisfies all of them. The second is a single point in that space — it must be exact, because there is precisely one right answer: the combination that actually worked.

pixi and Poetry are not rivals. They are tools for the two halves of that sentence.

A worked example that is not hypothetical

This website is built with pixi, and its repository makes the argument better than a toy example could.

The manifest declares one dependency:

# pixi.toml
[workspace]
channels = ["conda-forge"]
platforms = ["osx-arm64", "osx-64", "linux-64", "win-64"]

[dependencies]
quarto = ">=1.6,<2"

That one line resolves to a lockfile pinning 39 distinct packages — 78 platform-specific entries across the four platforms. Each entry looks like this:

# pixi.lock (excerpt)
- conda: https://conda.anaconda.org/conda-forge/linux-64/dart-sass-1.104.0-hfc2019e_0.conda
  sha256: 97b93231fc990f7f4eb287ccb069b4e85609fcba33c82676617820b37558e6a9
  md5: 9916a7c2f17695517982bd14d35763cf
  license: MIT
  size: 3981882
  timestamp: 1788502072242

Three things about that excerpt are worth dwelling on.

It is not a version number, it is a file. A URL, a sha256, a size. Re-run the install in 2031 and you do not get “whatever 1.104.0 means by then” — you get those exact bytes or an error.

The lockfile pins the platform, not just the packages. Alongside the package list it records virtual packages per platform — __glibc=2.28, __osx=13.0, __archspec — so the solve is tied to an ABI contract, not only to version strings.

And almost none of it is Python. Asking for Quarto pulls in dart-sass, deno, libgomp, _openmp_mutex. Quarto is not a Python tool. There is no wheel for Deno. pip could not have installed any of this, at any version, in any configuration — and that is the entire reason the conda-forge side of the ecosystem exists.

Then there is the part that matters most for this article:

This repository builds no package at all. No pyproject.toml, no wheel, no poetry build. It is an application — a website — so it pins hard and commits the lockfile. A library would do the precise opposite, and would be wrong to do anything else.

TipThe pin still holds

The CI workflow pins pixi-version: v0.72.2 while 0.80.0 is current, and the site still builds byte-for-byte as it did. That is not neglect; it is the lockfile doing its job. The workflow’s own comment says why: “Quarto comes from pixi.lock, so CI renders with the exact version used locally.”

The other half: what a package declares

Flip to the distribution side and every instinct reverses.

A package’s job is to be installed alongside software its author has never seen. So [project] in pyproject.toml carries abstract metadata — a name, a version, and dependency ranges — and the build backend (poetry-core, hatchling, setuptools, uv_build) turns it into a wheel. An illustrative manifest, not a published package:

[project]
name = "owl-budget"
version = "0.3.1"
requires-python = ">=3.10"
dependencies = [
  "pandas>=2.0",
  "pyyaml>=6.0",
]

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

Note what is absent: no hashes, no exact versions, no platform list. Not because the author was careless, but because every one of those would be a lie about someone else’s machine.

This is the half pixi does not do, and says so — its own FAQ marks package building as work in progress while marking Poetry’s as done. Use the right tool for the half you are in.

Where the confusion costs real money

If this were only taxonomy it would not be worth an article. It is worth one because the mistake propagates.

The clearest case is Poetry’s historical default of capping dependencies with the caret operator. poetry add requests would write ^2.31, meaning >=2.31,<3.0 — and new projects got a cap on the Python version itself by default.

That is an environment fact leaking into package metadata, and package metadata is transitive. Henry Schreiner’s analysis is the sharpest statement of the damage: cap Python in a library and “all Poetry users who depend on your package will have to have a cap on the Python version” — and they cannot override it. It persists downstream after the original author has moved on. His objection to the underlying reasoning is that you “can’t predict the future” about what a future release will break.

His summary of the category error is the thesis of this article, phrased better than I would phrase it: a tool that produces PyPI packages “should not force you to set a metadata slot as important as Requires-Python based on a lock file you are not even including in the package.”

In fairness to Poetry: caret ranges do work well for dependencies that follow semantic versioning strictly, the FAQ acknowledges the genuine trade-off — an unbounded >= avoids conflicts but risks breakage — and modern Poetry has largely corrected course, deprecating [tool.poetry] metadata in favour of the [project] standard since 2.0.

The mirror-image mistake is harmless, which is why it persists

Shipping a lockfile inside a library is the same confusion in the other direction. It does no damage — and that is exactly the problem, because nothing tells you it did nothing. Poetry’s documentation puts it in nine words:

The application ignores your library’s lock file.

A lockfile in a published library is inert. It is a file that looks like it is doing a job and is not.

There is a real use for it, though, and it is worth naming precisely: a library’s lockfile is a CI testing artefact, not a distribution artefact. Committing it pins what your test suite runs against, which is useful. Poetry advises refreshing it regularly so you are not testing a frozen past; pixi’s documentation makes the same carve-out, noting libraries “should test against multiple dependency versions”. Commit it for your CI, by all means. Just do not imagine your users will ever see it.

The line was drawn in 2020

None of this is a recent invention by tool authors. PEP 621 settled it before pixi or uv existed:

No tools may add fields to this table which are not defined by this PEP or subsequent PEPs. For tools wishing to store their own settings in pyproject.toml, they may use the [tool] table

So one file has two owners, and which table you are editing tells you which world you are in:

Table Owner What it is
[project] the standard (PEP 621) Abstract, distributable metadata. What a consumer’s installer reads.
[build-system] the standard (PEP 517/518) How a wheel gets built.
[tool.pixi.*] pixi The environment model — dependencies, tasks, platforms, features.
[tool.poetry] Poetry Build and packaging specifics with no standard equivalent.
[tool.uv] uv Index routing, sources, workspace configuration.

[project] is what you publish. [tool.*] is how you work. The [tool] namespace is the environment/package boundary, written into a standard in 2020. Everything above is a consequence of it.

This is also why “can I use pixi and Poetry together?” has a better answer than it usually gets. pixi’s docs do advise against mixing them — but read the warning precisely: it is about running two resolvers over the same site-packages and having them fight. Using pixi for the environment while [build-system] names poetry-core is not mixing. Those touch entirely disjoint machinery: a build backend is invoked through PEP 517, not competing for your site-packages.

NoteOne detail that gives the whole game away

pixi’s migration guide tells you to make sure python is declared in [tool.pixi.dependencies] and never in pypi-dependencies. To pixi, the interpreter is just another system dependency to solve for. To Poetry, Python is a constraint you declare about the outside world. Same word, two worlds.

Where uv actually lands

Any honest version of this article in 2026 has to deal with uv, because “just use uv” is now the default opinion and the course material this section grew out of predates uv’s rise.

The short answer: uv straddles the boundary, and that is a genuine strength, but it does not dissolve it.

uv resolves, installs, manages virtualenvs and Python versions, writes a uv.lock, and also builds and publishes packages. For pure-Python work it can credibly do both halves. Two things are worth knowing before concluding it wins outright.

It is not pixi’s rival at the resolver layer — it is pixi’s component. In pixi’s own words: “Pixi fully supports PyPI packages alongside conda packages, powered by uv under the hood.” The piece of pixi that talks to PyPI already is uv.

And the limit is architectural, not a roadmap item. uv resolves from PyPI, Git, local paths and private indexes. It cannot install the CUDA toolkit, GDAL or HDF5, because those are not Python packages. No amount of uv development changes that without uv becoming a conda client.

The canonical example is GDAL, not PyTorch — worth correcting because PyTorch is the one everybody reaches for and it is the weaker case. uv handles PyTorch’s CUDA variants well via index routing and --torch-backend=auto. GDAL is the one that ends in a gdal-config build error no amount of pinning prevents. The real break is mixed GPU stacks, where a single solver pass has to reconcile CUDA versions across several ecosystems at once.

A reasonable decision table, adapted from Tim Hopper’s comparison:

Situation Reach for
Pure Python, everything on PyPI uv
Native libraries, new project pixi
Native libraries, existing conda estate conda
PyTorch only uv
Mixed GPU stacks (PyTorch + RAPIDS) pixi or conda
Building and publishing a package Poetry, hatchling, or uv

Being fair about all three

None of these tools is neutral and all three deserve their criticism stated.

pixi is pre-1.0, and its entire advantage reduces to “conda-forge has the package” — which means its moat is somebody else’s volunteer-maintained collection. Its conda-then-PyPI two-pass resolution has a documented seam where a single solve would have succeeded. prefix.dev sells hosted conda channels, so a free tool that makes private channels more valuable has a commercial logic worth naming. And sustained public criticism of pixi is genuinely scarce, which usually indicates a tool without enough users rather than one without flaws.

Poetry resolves slowly — for a real reason it did not cause, namely that PyPI metadata is often unavailable without downloading the package — was four years late to PEP 621, and did ecosystem damage with default caps.

uv is pre-1.0, its widely-repeated speed multiplier is published as chart images rather than extractable numbers against a single small workload, it has no task runner, uv pip inherits pip’s semantics along with pip’s problems, and Astral was acquired by OpenAI — announced March 2026. The licence is irrevocable and the release cadence has held, which is the strongest reassurance available; the honest caveat is that Astral’s commercial registry was absent from the announcements.

And on lock-in specifically, apply one standard or none: “The uv.lock format is specific to uv and not usable by other tools.” No lockfile currently in use is portable between tools. That is precisely why PEP 751 — a standard pylock.toml — was accepted in 2025.

What none of this buys you

A closing caution, because the word “reproducible” does a lot of unearned work.

The Turing Way separates four things people routinely conflate:

Same data Different data
Same analysis Reproducible Replicable
Different analysis Robust Generalisable

A result is reproducible when “the same analysis steps performed on the same dataset consistently produces the same answer”.

Only the top-left cell is a tooling problem. A lockfile delivers reproducibility exactly, and delivers exactly nothing else. Robustness requires a different analysis — the R pipeline and the Python pipeline agreeing — which means a different environment by definition. Replicability requires different data.

So a perfect pixi.lock guarantees that your wrong answer is wrong the same way on every machine, forever. That is genuinely valuable and it is not the same as being right. If you came to environment management hoping it would make your research robust, the cell you want is the one tooling cannot reach.

TipThe best argument for pixi is a Turing Way footnote

The Turing Way’s advice for conda users is to install as much as possible with conda first, then pip, and never conda again — because “running Conda after pip may potentially overwrite or break packages installed via pip”.

That ordering rule, a hand-maintained discipline you had to remember, is precisely what pixi automated: resolve conda, then resolve PyPI with the conda packages held fixed. A best practice became a solver invariant. That is what progress in this area actually looks like.

What to do on Monday

  • Ask which artefact you are making. An analysis, a website, a service, a paper: pin exactly, commit the lockfile. A library other people will install: declare ranges, and do not cap unless you have a specific reason you could defend to a stranger in 2031.
  • If your stack is pure Python, uv is the reasonable default and you can stop reading here.
  • If anything in your stack is not Python — geospatial, CUDA, HDF5, compilers, R interop — that is what pixi and conda-forge are for, and no amount of PyPI tooling will substitute.
  • Keep runtime dependencies in [project.dependencies], not in a tool-specific table. It is the one piece of your configuration that survives changing your mind about all of this.
  • Commit the lockfile for your CI even in a library. Just know your users will never see it.

Then read the companion article, which puts all of this inside a working repository — data, tested module, analysis script and locked environment, in eleven files.

Sources

Research notes, with every figure’s provenance and what could not be verified, are in the accompanying folder.

  • The abstract/concrete distinction. PyPA, install_requires vs requirements files.
  • The standard that drew the line. PEP 621 (2020); PEP 517; and PEP 751, the pylock.toml standard, accepted March 2025.
  • pixi. Documentation, including the lockfile reference, the conda/PyPI resolution model and the Poetry migration guide.
  • Poetry. Basic usage (the “application ignores your library’s lock file” quote), libraries and the FAQ.
  • The capping argument. Henry Schreiner, Should You Use Upper Bound Version Constraints? (2021, updated 2026).
  • uv. Documentation, including project layout and the PyTorch integration guide. On the acquisition: Simon Willison, OpenAI is acquiring Astral (19 March 2026).
  • Comparisons. Tim Hopper’s pydevtools handbook; Jacob Tomlinson, Python package managers: uv vs pixi (2025).
  • Definitions and environment guidance. The Turing Way.

© 2026 Collegica

A learning companion to Mechatronics3D

  • About

  • Events

  • Talks