Your First Project with pixi
One environment with GDAL and OpenCV from conda-forge, pandas and scikit-learn from PyPI, a task, a test and a lockfile that works on three operating systems
In Next-Gen Development Environments I compared pixi with Poetry, Conda, uv and a few others. The short version: pixi earns its place when a project needs things that are not Python packages, such as the GDAL geospatial library, a CUDA toolkit or a compiler.
If you have followed Publish Your First Python Package, you have used Poetry to build something other people install. This guide is about the other job: an analysis project that has to run, the same way, on your machine and on someone else’s. It mixes packages from conda-forge (GDAL, OpenCV) with packages from PyPI (pandas, scikit-learn). That mix is awkward with tools that only speak PyPI, and ordinary work for pixi.
Every command here was run, in order, from an empty folder, and the finished project is in the research folder.
- Install pixi, then
pixi init. It writes apixi.tomlwith a[workspace]table and only your own platform listed. Add the others withpixi workspace platform add. pixi addfor conda-forge,pixi add --pypifor PyPI. Addpythonitself as a conda-forge package. Where both sides need the same library, such as numpy, pixi installs it once, from conda-forge.- Tasks replace the README’s “how to run it” section.
pixi run analyseinstalls anything missing, then runs. Withinputsandoutputs, an unchanged task is skipped. - Features keep test tools out of the main environment. Three commands give you a
devenvironment with pytest;pixi run testfinds it. - Commit
pixi.tomlandpixi.lock. A colleague runspixi install --lockedand gets the same packages. On Linux the example produced a byte-identical GeoTIFF to the one made on a Mac.
Tested on 13 November 2026 with pixi 0.78.0 on macOS (Apple silicon), then repeated on Linux (x86_64, in Docker) with 0.78.0 and with 0.84.2, the current release. Windows was not tested. Install the current version: 0.82.1 was a security release that pixi’s authors asked everyone to upgrade to.
The packages resolved that day were Python 3.15.2, GDAL 3.13.3, OpenCV 5.0.0, numpy 2.5.4, pandas 3.1.1, scikit-learn 1.9.1 and pytest 9.2. You will get newer versions, and the version ranges in your pixi.toml will differ from the ones shown below.
What you will build
A small project called river-gauges. It reads a table of eight made-up river gauges, then:
- fits flow against catchment area with scikit-learn,
- spreads the readings over a 50 × 50 grid with numpy,
- writes the grid as a GeoTIFF with GDAL,
- smooths and colours it into a picture with OpenCV,
- and reads the table with pandas.
GDAL is the reason for the tool. Its Python package on PyPI is a source archive with no prebuilt wheels, and building it needs the GDAL C library already installed. Without that, pip or uv stop with “Could not find gdal-config”. conda-forge ships GDAL prebuilt, with the C library inside.
OpenCV, to be fair, has wheels on PyPI and would install fine on its own. It comes from conda-forge here so that GDAL and OpenCV share one set of image libraries.
You need a terminal and an internet connection. You do not need Python, conda or any earlier pixi knowledge. The first install downloads close to 1 GB of packages.
1. Install pixi
On macOS or Linux:
curl -fsSL https://pixi.sh/install.sh | shOn Windows, in PowerShell:
powershell -ExecutionPolicy Bypass -c "irm -useb https://pixi.sh/install.ps1 | iex"or winget install prefix-dev.pixi. On a Mac with Homebrew, brew install pixi works too.
The script puts pixi in ~/.pixi/bin and adds that folder to your shell’s PATH, so open a new terminal afterwards. It also sends one anonymous install ping; set PIXI_NO_TELEMETRY=1 before running it if you would rather it did not. Then check:
pixi --version2. Create the workspace
pixi init river-gauges
cd river-gaugespixi creates the folder with three files: pixi.toml, a .gitignore that keeps the environment out of git, and a .gitattributes. The manifest looks like this:
[workspace]
authors = ["Sam Rivera <[email protected]>"]
channels = ["conda-forge"]
name = "river-gauges"
platforms = ["osx-arm64"]
version = "0.1.0"
[tasks]
[dependencies]authors comes from your git settings, if you have any. platforms lists only the machine you are on: osx-arm64 on an Apple silicon Mac, linux-64 on most Linux machines, win-64 on Windows. There is no pixi.lock yet; it appears when you add the first package.
Older guides, including the first draft of this one, show a [project] table here. pixi now writes [workspace] and treats [project] as an old name for it.
3. List the platforms you want to support
The lockfile only covers the platforms in that list, so add the others now:
pixi workspace platform add linux-64 win-64Use whichever names you are missing. On a Linux machine you would add osx-arm64 win-64. pixi now solves every package for all three platforms, even though it installs only for yours.
4. Add packages from conda-forge
pixi add python gdal opencv✔ Added python >=3.15.2,<3.16
✔ Added gdal >=3.13.3,<4
✔ Added opencv >=5.0.0,<6
This took five seconds with an empty cache on a fast connection, and downloaded about 900 MB. pixi writes each package into pixi.toml with a version range, solves for all three platforms, writes pixi.lock, and installs the environment into a .pixi folder inside the project.
Add python explicitly. pixi needs a Python from conda-forge before it can install anything from PyPI.
5. Add packages from PyPI
pixi add --pypi pandas scikit-learn✔ Added pandas >=3.1.1, <4
✔ Added scikit-learn >=1.9.1, <2
Added these as pypi-dependencies.
They go in a separate table:
[dependencies]
python = ">=3.15.2,<3.16"
gdal = ">=3.13.3,<4"
opencv = ">=5.0.0,<6"
[pypi-dependencies]
pandas = ">=3.1.1, <4"
scikit-learn = ">=1.9.1, <2"pandas needs numpy, and so do GDAL and OpenCV. pixi solves the conda-forge side first, then the PyPI side with the conda-forge packages held fixed. It knows that conda-forge’s numpy is the same thing as PyPI’s numpy, so it installs numpy once and pandas uses that copy. pixi list shows where each package came from:
$ pixi list
…
numpy 2.5.4 … conda https://conda.anaconda.org/conda-forge
pandas 3.1.1 … pypi https://pypi.org/simple
scikit_learn 1.9.1 … pypi https://pypi.org/simple
…
Check that everything imports:
pixi run python -c "from osgeo import gdal; import cv2, pandas, sklearn; print(gdal.__version__, cv2.__version__, pandas.__version__, sklearn.__version__)"3.13.3 5.0.0 3.1.1 1.9.1
GDAL’s Python module is osgeo.gdal. import gdal fails with ModuleNotFoundError.
6. Add the data and the script
Make a data folder and save this as data/gauges.csv. The stations are invented.
station,lon,lat,catchment_km2,flow_m3s
Alder Ford,-1.42,52.81,48,2.6
Brackenmouth,-1.05,52.64,310,10.4
Cinder Weir,-1.31,52.55,122,5.9
Dunmere Bridge,-0.88,52.77,205,7.2
Elmshaw,-1.18,52.92,66,3.4
Fallowby Mill,-0.97,52.49,388,15.8
Greywater Lock,-1.25,52.70,174,5.1
Hollin Bend,-0.79,52.58,452,16.0
Save this as analyse.py in the project folder:
"""River gauges: a first pixi project.
Reads a small table of made-up river gauges, fits flow against catchment
area with scikit-learn, spreads the readings over a grid, writes the grid
as a GeoTIFF with GDAL, and smooths it into a picture with OpenCV.
"""
from pathlib import Path
import cv2
import numpy as np
import pandas as pd
from osgeo import gdal, osr
from sklearn.linear_model import LinearRegression
gdal.UseExceptions()
DATA = Path("data/gauges.csv")
OUT = Path("output")
SIZE = 50 # cells along each side of the grid
MARGIN = 0.05 # degrees of padding around the gauges
def fit_flow(gauges: pd.DataFrame) -> tuple[float, float]:
"""Return the slope (m3/s per km2) and R squared of flow against catchment."""
x, y = gauges[["catchment_km2"]], gauges["flow_m3s"]
model = LinearRegression().fit(x, y)
return float(model.coef_[0]), float(model.score(x, y))
def grid_bounds(gauges: pd.DataFrame) -> tuple[float, float, float, float]:
"""West, east, south and north edges of the grid, in degrees."""
return (
gauges.lon.min() - MARGIN,
gauges.lon.max() + MARGIN,
gauges.lat.min() - MARGIN,
gauges.lat.max() + MARGIN,
)
def idw_grid(gauges: pd.DataFrame, bounds, size: int = SIZE) -> np.ndarray:
"""Spread the gauge readings over a size x size grid by inverse-distance weighting."""
west, east, south, north = bounds
dx, dy = (east - west) / size, (north - south) / size
xs = west + (np.arange(size) + 0.5) * dx # cell centres, west to east
ys = north - (np.arange(size) + 0.5) * dy # cell centres, north to south
gx, gy = np.meshgrid(xs, ys)
dist = np.hypot(gx[..., None] - gauges.lon.to_numpy(), gy[..., None] - gauges.lat.to_numpy())
weight = 1.0 / np.maximum(dist, 1e-9) ** 2
return (weight * gauges.flow_m3s.to_numpy()).sum(axis=-1) / weight.sum(axis=-1)
def write_geotiff(grid: np.ndarray, bounds, path: Path) -> None:
"""Write the grid as a single-band GeoTIFF in WGS 84 (EPSG:4326)."""
west, east, south, north = bounds
rows, cols = grid.shape
ds = gdal.GetDriverByName("GTiff").Create(str(path), cols, rows, 1, gdal.GDT_Float32)
ds.SetGeoTransform((west, (east - west) / cols, 0, north, 0, -(north - south) / rows))
srs = osr.SpatialReference()
srs.ImportFromEPSG(4326)
ds.SetProjection(srs.ExportToWkt())
ds.GetRasterBand(1).WriteArray(grid.astype(np.float32))
ds = None # closing the dataset writes it to disk
def write_png(grid: np.ndarray, path: Path) -> None:
"""Smooth the grid, colour it and save it as a 400 x 400 picture."""
scaled = cv2.normalize(grid.astype(np.float32), None, 0, 255, cv2.NORM_MINMAX)
smooth = cv2.GaussianBlur(scaled.astype(np.uint8), (7, 7), 0)
big = cv2.resize(smooth, (400, 400), interpolation=cv2.INTER_CUBIC)
cv2.imwrite(str(path), cv2.applyColorMap(big, cv2.COLORMAP_VIRIDIS))
def main() -> None:
gauges = pd.read_csv(DATA)
print(f"{len(gauges)} gauges, flow {gauges.flow_m3s.min()} to {gauges.flow_m3s.max()} m3/s")
slope, r2 = fit_flow(gauges)
print(f"Flow rises {slope:.3f} m3/s per km2 of catchment (R squared {r2:.2f})")
bounds = grid_bounds(gauges)
grid = idw_grid(gauges, bounds)
OUT.mkdir(exist_ok=True)
tif, png = OUT / "flow.tif", OUT / "flow.png"
write_geotiff(grid, bounds, tif)
check = gdal.Open(str(tif))
band = check.GetRasterBand(1).ReadAsArray()
epsg = osr.SpatialReference(wkt=check.GetProjection()).GetAuthorityCode(None)
print(f"Wrote {tif}: {check.RasterXSize} x {check.RasterYSize} cells, EPSG:{epsg}, "
f"mean flow {band.mean():.1f} m3/s")
write_png(grid, png)
print(f"Wrote {png}")
if __name__ == "__main__":
main()The script makes an output folder. Add output/ to the end of .gitignore so the results stay out of git.
7. Turn the command into a task
You could run pixi run python analyse.py. A task gives the command a name that anyone who clones the project can find:
pixi task add analyse "python analyse.py" --description "Grid the gauge readings into a GeoTIFF and a picture"
pixi run analyse✨ Pixi task (analyse): python analyse.py: (Grid the gauge readings into a GeoTIFF and a picture)
8 gauges, flow 2.6 to 16.0 m3/s
Flow rises 0.035 m3/s per km2 of catchment (R squared 0.96)
Wrote output/flow.tif: 50 x 50 cells, EPSG:4326, mean flow 8.0 m3/s
Wrote output/flow.png
output/flow.png is a 400 × 400 picture: flow is highest in the south-east, where the two largest catchments are. GDAL’s command-line tools came with the package, so you can inspect the GeoTIFF too:
pixi run gdalinfo -stats output/flow.tifpixi task list shows the tasks and their descriptions. pixi run checks the environment against pixi.toml before each run, so a colleague who has only just cloned the project can type pixi run analyse and pixi installs what is missing first.
If you would rather type commands directly, pixi shell starts a shell with the environment active, and exit leaves it. Tasks are better for anything someone else will need to run.
Skip work that is already done
Open pixi.toml and rewrite the task as a table with its inputs and outputs:
[tasks.analyse]
cmd = "python analyse.py"
description = "Grid the gauge readings into a GeoTIFF and a picture"
inputs = ["analyse.py", "data/gauges.csv"]
outputs = ["output/flow.tif", "output/flow.png"]Run it twice. The second time pixi notices that nothing has changed:
Task 'analyse' can be skipped (cache hit) 🚀
pixi fingerprints the files that inputs and outputs name. It skips the task only if those files, the command and the installed packages are all unchanged. For one small script that saves a few seconds. For a pipeline that takes an hour, it is the difference between rerunning everything and rerunning the step you changed.
8. Keep test tools in a separate environment
pytest is only for development. A feature is a named group of packages and tasks, and an environment is built from one or more features. These three commands make a dev environment that has everything in the default one plus pytest:
pixi add --feature dev pytest
pixi workspace environment add dev --feature dev
pixi upgrade --feature dev pytestThe first command warns that the dev feature is not used by any environment yet. That is expected, and the second command fixes it. The third replaces the "*" the first one wrote with a real version range. The order matters: pixi refuses to make an environment from a feature that does not exist yet.
Add a test task to the feature:
pixi task add --feature dev test "python -m pytest -q tests"and save a test as tests/test_analyse.py:
import numpy as np
import pandas as pd
from analyse import grid_bounds, idw_grid
GAUGES = pd.DataFrame(
{"lon": [0.0, 1.0], "lat": [0.0, 1.0], "catchment_km2": [10, 20], "flow_m3s": [2.0, 4.0]}
)
def test_grid_stays_within_the_readings():
grid = idw_grid(GAUGES, grid_bounds(GAUGES), size=10)
assert grid.shape == (10, 10)
assert 2.0 <= grid.min() and grid.max() <= 4.0
def test_grid_is_highest_near_the_higher_gauge():
grid = idw_grid(GAUGES, grid_bounds(GAUGES), size=10)
# Row 0 is the north edge, column -1 the east edge: nearest the (1, 1) gauge.
assert np.isclose(grid[0, -1], grid.max())pixi run test✨ Pixi task (test in dev): python -m pytest -q tests
.. [100%]
2 passed
You did not need -e dev. The test task exists only in the dev environment, so pixi picks it. The manifest now ends with:
[feature.dev.tasks]
test = "python -m pytest -q tests"
[feature.dev.dependencies]
pytest = ">=9.2.0,<10"
[environments]
dev = ["dev"]The same pattern gives you a gpu environment with CUDA packages, or a py313 environment that pins an older Python for comparison. pixi’s multi-environment guide covers those.
When a package is not on conda-forge
Use pixi add --pypi. That covers most packages, including ones from a git repository:
pixi add --pypi "some-package @ git+https://github.com/example/some-package"The first draft of this guide suggested pip install inside pixi shell as a fallback. Do not do that. The Python from conda-forge in this environment has no pip. Even where pip is present, anything it installs is missing from pixi.toml and pixi.lock, so your colleague never gets it.
If a package is on both, prefer the conda-forge one when it is a wrapper around a C or C++ library (GDAL, PROJ, HDF5, CUDA). Prefer PyPI for pure Python, or when the conda-forge build is behind.
Coming from Poetry
| You want to | Poetry | pixi |
|---|---|---|
| Start a project | poetry new name |
pixi init name |
| Add a PyPI package | poetry add requests |
pixi add --pypi requests |
| Add a conda-forge package | — | pixi add gdal |
| Install from the lockfile | poetry install |
pixi install (or just pixi run) |
| Run something | poetry run pytest |
pixi run pytest |
| Named commands | — | [tasks] |
| Build and publish a package | poetry build, poetry publish |
not pixi’s job yet |
pixi keeps its settings in pixi.toml, as here, or in a Python project’s pyproject.toml under [tool.pixi.*] (pixi init --format pyproject). pixi’s documentation suggests pyproject.toml when the project is a Python package, and pixi.toml otherwise. This project is an analysis, not a package, so pixi.toml fits.
They are not rivals. pixi makes the environment; Poetry, or uv, builds the package. Environments Are Not Packages explains the difference and when uv alone is enough.
What this guide does not cover
pixi global, for installing command-line tools for the whole machine (pixi global install <tool>), each in its own environment. It has its own guide.- Building conda packages from your own code.
- GPUs. The CUDA example in the first draft was not tested and is left out.
- Windows, which was not run. The lockfile includes
win-64, and one detail there was not checked: it lists numpy from both conda-forge and PyPI on Windows only.
Sources
The test log, the list of documentation pages read and when, and the finished example are in the research folder.
- pixi. Installation; manifest reference; tasks; multiple environments; lock file; how conda and PyPI are combined; pyproject.toml; switching from Poetry; releases, 0.78.0 to 0.84.2.
- GDAL on PyPI. GDAL: a source archive only, which needs the GDAL C library to build.
- Poetry. Repositories: Poetry installs from PyPI and other Python package indexes.
- The comparison. Next-Gen Development Environments (Substack, March 2025) and Environments Are Not Packages.