:focal(smart))
Introducing Pixi Build
Pixi is a cross-platform, cross-language package manager and it is great! It heavily relies on the Conda ecosystem, which is binary only. This means that as far as Pixi is concerned:
you tell Pixi which packages you want and where it should get them from,
our extremely fast solver finds a combination of packages that satisfies your requirements,
these packages are extracted into one folder per environment, and then you
start defining Pixi tasks which are run in an activated environment.
What has been missing until now is a way to build a package natively within Pixi. We are working on making that possible, but first let me show you with a simple example why that makes many workflows so much more powerful!
A Simple Python Script
We start with a fresh workspace:
$ pixi init fibtable $ cd fibtable
Then we add both python and the Python library rich as a dependency:
# pixi.toml [dependencies] python = ">=3.14.6,<3.15" rich = ">=15.0.0,<16"
Let’s also write a simple script to calculate the nth Fibonacci number:
# fibtable.py def fib(n: int) -> int: """The nth Fibonacci number.""" a, b = 0, 1 for _ in range(n): a, b = b, a + b return a
Finally, we make use of our rich dependency by wrapping the result into a nice table.
# fibtable.py def main() -> None: console = Console() table = Table(title="Fibonacci") table.add_column("n", justify="right") table.add_column("fib(n)", justify="right") for n in (10, 20, 30): table.add_row(str(n), str(fib(n))) console.print(table)
When we then want to run our program, we typically want to first define a task. Tasks are great since they document the ways in which your Pixi workspace can be used, and they also allow you to compose multiple tasks into one workflow.
We start with a simple one that runs our Python script.
# pixi.toml [tasks] start = "python fibtable.py"
And it works!
$ pixi run start Fibonacci ┏━━━━┳━━━━━━━━┓ ┃ n ┃ fib(n) ┃ ┡━━━━╇━━━━━━━━┩ │ 10 │ 55 │ │ 20 │ 6765 │ │ 30 │ 832040 │ └────┴────────┘
Rewrite It in Rust
The Fibonacci function is now written in pure Python, which is probably slow? Without measuring anything, let’s go ahead and rewrite it in Rust!
Add a Rust crate inside the same workspace:
$ cargo init fiboxide
Now, we add the same function to our Rust code:
// fiboxide/src/main.rs /// The nth Fibonacci number. fn fib(n: u64) -> u64 { let (mut a, mut b) = (0, 1); for _ in 0..n { (a, b) = (b, a + b); } a }
We only have a single function that takes one number and returns one number. The simplest way of exposing that to the Python code is to provide a CLI that takes the input number as argument and then prints the result to stdout.
// fiboxide/src/main.rs fn main() { let n: u64 = env::args().nth(1).unwrap().parse().unwrap(); println!("{}", fib(n)); }
However, how does the Python script know where to find the Rust binary? Since it isn’t an installed package in our environment, we can’t just expect it to be in the PATH.
The best thing I can think of right now is to expect the binary at a certain location relative to the Python script. This is ugly but it works. (Please don't stop reading here, it will get better, I promise!)
# fibtable.py # The binary lands under target/release, and gets a .exe suffix on Windows. BINARY = ( Path(__file__).parent / "fiboxide" / "target" / "release" / ("fiboxide.exe" if sys.platform == "win32" else "fiboxide") )
We can then run the Rust binary and extract the output as an integer.
# fibtable.py def fib(n: int) -> int: result = subprocess.run( [str(BINARY), str(n)], capture_output=True, text=True, check=True ) return int(result.stdout)
In order to be able to build the Rust crate, we need to add rust to our dependency list.
# pixi.toml [dependencies] python = ">=3.14.6,<3.15" rich = ">=15.0.0,<16" rust = ">=1.96.0,<1.97"
Pixi tasks make the whole thing a bit more bearable. Now, at least we don’t have to remember to pass --release when building the CLI and thanks to depends-on we can be sure the CLI is built every time.
# pixi.toml [tasks] build-cli = "cargo build --release --manifest-path fiboxide/Cargo.toml" start = { cmd = "python fibtable.py", depends-on = ["build-cli"] }
When we now execute the start task, everything is handled transparently:
$ pixi run start ✨ Pixi task (build-cli): cargo build --release --manifest-path fiboxide/Cargo.toml Compiling fiboxide v0.1.0 Finished `release` profile [optimized] target(s) ✨ Pixi task (start): python fibtable.py Fibonacci ┏━━━━┳━━━━━━━━┓ ┃ n ┃ fib(n) ┃ ┡━━━━╇━━━━━━━━┩ │ 10 │ 55 │ │ 20 │ 6765 │ │ 30 │ 832040 │ └────┴────────┘
We made it work, but is it great? Absolutely not!
We had to hardcode the path to one of our dependencies in the code and that path was even different across operating systems. Dependencies that are needed for building are mixed with the ones needed for running. After all, we don’t need the Rust compiler to run a Rust executable. All of that makes it very difficult to hand your application to someone else so they can depend on it themselves. Even if it’s only one team working on a single monorepo, that workflow scales poorly.
Enter Pixi Build
What you want instead is that every application and library that you develop on your system translates to its own package that Pixi is aware of. That way you get all the goodies that you are used to from binary packages like a solver that ensures that your packages are actually compatible. There are also source-dependency-specific features like Pixi taking care that dependencies are properly cached and built in the correct order.
Okay, so let’s think about how we organize this. We have a Rust application fiboxide and a Python application fibtable. fibtable needs fiboxide to function, in other words it depends on fiboxide. Therefore, let’s define the fiboxide Pixi package first.
We create another pixi.toml in the folder fiboxide that only defines the name of a build backend called “pixi-build-rust”.
# fiboxide/pixi.toml [package.build.backend] name = "pixi-build-rust"
Pixi build backends are heavily inspired by Python build backends. With Python, a build backend is an application that takes configuration and source code to build a Python package. With Pixi, a build backend builds a conda package.
The bare minimum a conda package needs is a name and a version, so why don’t we need that here? This is because the Cargo.toml that was generated by cargo init before already contains that metadata and pixi-build-rust simply takes it from there.
# fiboxide/Cargo.toml [package] name = "fiboxide" version = "0.1.0" edition = "2021" [[bin]] name = "fiboxide" path = "src/main.rs"
Unlike with fiboxide, fibtable doesn’t have a project setup yet. We always ran the single file directly. Let’s change that by creating a pyproject.toml .
# pyproject.toml [build-system] build-backend = "hatchling.build" requires = ["hatchling"] [project] name = "fibtable" version = "0.1.0" requires-python = "~=3.14" dependencies = ["rich"] [project.scripts] fibtable = "fibtable:main"
Here we see the Python build backend in action. hatchling is responsible for building the Python package.
Since conda packages are language-agnostic, Pixi build backends function on a higher layer of abstraction than Python build backends. We can see this in the package definition of fibtable. The real power comes from the dependencies. Those include hatchling in host-dependencies that are used during package build.pixi-build-python doesn’t have to worry about how exactly the Python package is built, it simply calls uv pip install and takes whatever hatchling produces. We can even include fiboxide that itself is built from source!
# pixi.toml [package.build.backend] name = "pixi-build-python" [package.host-dependencies] hatchling = "*" [package.run-dependencies] rich = ">=15.0.0,<16" fiboxide = { path = "fiboxide" }
In our case, we add fiboxide to our run-dependencies. “run” because you need that dependency when you run the application, unlike hatchling , which is needed when the package is built.
fibtable can now simply expect that fiboxide is in the PATH instead of locating the binary by itself:
# src/fibtable/__init__.py def fib(n: int) -> int: # `fiboxide` is on PATH: it was built from source and installed into the environment. result = subprocess.run( ["fiboxide", str(n)], capture_output=True, text=True, check=True ) return int(result.stdout)
So far so good. Here comes a big disclaimer though. We really want to get this right, so for now we reserve the option to make breaking changes if we think it improves things in the long run. That’s why we require you to manually opt in your workspace with preview = ["pixi-build"] to use Pixi Build.
# pixi.toml [workspace] channels = ["https://prefix.dev/conda-forge"] name = "fibtable" platforms = ["linux-64", "osx-64", "osx-arm64", "win-64"] preview = ["pixi-build"]
Let’s give it a spin! With pixi global we can install fibtable globally.
pixi global install --path .
Running fibtable just works.
$ fibtable Fibonacci ┏━━━━┳━━━━━━━━┓ ┃ n ┃ fib(n) ┃ ┡━━━━╇━━━━━━━━┩ │ 10 │ 55 │ │ 20 │ 6765 │ │ 30 │ 832040 │ └────┴────────┘
I want to quickly draw your attention to how many things are handled for you. fibtable needs fiboxide and Pixi just built it for you. fiboxide needs the Rust compiler to be built, but the Rust compiler is not needed to run it. So we shouldn’t see it when we run pixi global list --environment fibtable and indeed we don’t! pixi-build-rust added the Rust compiler to fiboxide’s build dependencies to make that work. Even though fiboxide is a CLI just like fibtable, it doesn’t pollute our PATH. Running fiboxide returns with an error, since pixi global makes sure only the packages we directly requested are exposed.
With pixi publish, we could build fiboxide and fibtable and publish them to a channel for other people to consume. For now, this requires you to manually build and publish them in order, but our vision is that this too will be handled by pixi publish in the future.
Local Development
Of course, we also need a way to develop on our packages locally. Adding fibtable to our dependencies just works.
# pixi.toml [dependencies] fibtable = { path = "." }
The Pixi task start now simply calls fibtable.
# pixi.toml [tasks] start = "fibtable"
As before, everything is handled for you and just works:
$ pixi run start Compiling fiboxide v0.1.0 Building fibtable ✨ Pixi task (start): fibtable Fibonacci ┏━━━━┳━━━━━━━━┓ ┃ n ┃ fib(n) ┃ ┡━━━━╇━━━━━━━━┩ │ 10 │ 55 │ │ 20 │ 6765 │ │ 30 │ 832040 │ └────┴────────┘
However, there is one big problem. Remember when I told you that the Rust compiler isn’t pulled in by depending on fibtable? How are we supposed to work on fiboxide if we don’t have a Rust toolchain?
That’s what the [dev] table fixes. Every dependency that is declared here isn’t actually installed but pulls in all its transitive dependencies - no matter if they are build, host or run dependencies.
# pixi.toml [dev] # rust + cargo land in the environment without rebuilding the whole package, # so `pixi run cargo build --manifest-path fiboxide/Cargo.toml` is a fast loop. fiboxide = { path = "fiboxide" }
Now the following command just works again.
$ pixi run cargo build --manifest-path fiboxide/Cargo.toml Finished `dev` profile [unoptimized + debuginfo] target(s)
However, the workflow is still a bit awkward. Every change to the Rust code will first trigger a rebuild of fiboxide and then your cargo build starts from fresh again since they don’t share the same target directory.
Our current idea to remedy this is to introduce package tasks. The build backend would expose an install task that you can just run manually. In our example, we wouldn’t add fibtable to the [dependencies] table and would instead simply call install for the package we want to update.
Building Packages Outside Our Repository
So far so good, I hope we have now established that Pixi Build is great for managing the packages in your repository. However, what if you want to build a package from source that lives outside your repository?
That works, and it’s especially convenient if that package contains a Pixi package manifest. Even though Pixi Build is still in preview, a couple of important packages have that already, including the official Python interpreter CPython and libraries such as SciPy, Xarray and Dask.
Let’s say we want to swap Python from conda-forge directly with Python from https://github.com/python/cpython. We use the freethreading variant so we at least get something out of that and pin it to a certain commit, so this blog post can be reproduced for at least a while.
# pixi.toml [dependencies] fibtable = { path = "." } # Swap the prebuilt interpreter for a free-threaded CPython, built from source. python = { git = "https://github.com/python/cpython", subdirectory = "Tools/pixi-packages/freethreading", rev = "59b41c8" }
We then add a Pixi task gil to show that we actually use free-threaded Python here with the GIL disabled.
# pixi.toml [tasks] start = "fibtable" # Show that the GIL really is gone. gil = """python -c 'import sys; print("free-threaded:", not sys._is_gil_enabled())'"""
And that is it.
The program still works.
$ pixi run start Fibonacci ┏━━━━┳━━━━━━━━┓ ┃ n ┃ fib(n) ┃ ┡━━━━╇━━━━━━━━┩ │ 10 │ 55 │ │ 20 │ 6765 │ │ 30 │ 832040 │ └────┴────────┘
And we can confirm that we indeed use the free-threaded Python version.
$ pixi run gil free-threaded: True
Inline Manifests
For most packages out there, you won’t be lucky enough for them to have a Pixi package manifest (for now at least!). But luckily, many of them are very simple to build. Take xsv, a CSV toolkit written in Rust. It hasn’t seen a commit since 2025, so of course it doesn’t contain a Pixi package manifest.
However, like many Rust applications, running cargo install is all you need to build and install it from source. Which is exactly what pixi-build-rust does! Therefore, all we need is a convenient way of describing how a package is built while declaring it as a dependency. That's what inline manifests are:
# pixi.toml [dependencies] fibtable = { path = "." } xsv = { git = "https://github.com/BurntSushi/xsv.git", package.build.backend.name = "pixi-build-rust" }
You simply declare everything you need from [package] and be done with it. For your own packages you probably still want to declare the package manifest outside [dependencies], but for external packages this is really powerful.
I've already shown you the important part, but since we have this dependency, let’s also put it to good use. Instead of rich we will use xsv to render a table. First, we need to output the data as valid CSV.
# src/fibtable/__init__.py def main() -> None: # Emit CSV; `xsv table` renders it as an aligned table. print("n,fib(n)") for n in (10, 20, 30): print(f"{n},{fib(n)}")
Within the Pixi task start we pipe the output straight into xsv table:
# pixi.toml [tasks] # `fibtable` prints CSV; xsv, built from source, renders it as a table. start = "fibtable | xsv table"
If we now run start, xsv instead of rich renders our table.
$ pixi run start n fib(n) 10 55 20 6765 30 832040
Inline manifests are powerful, especially for early Pixi Build adoption. In the future, I would like to see something similar for pixi global. It would be great to run the following command, and it installs the Rust tool you just found on hacker news.
# This invocation only exists in my head so far, don't try this at home pixi global install --git https://github.com/BurntSushi/xsv.git --build-backend pixi-build-rust
Conclusion
Let's summarize what we learned about Pixi Build. It allows you to:
Manage your own code as individual packages, including automatic rebuilds and caching.
Publish those packages to channels for others to consume as binaries.
Depend on external code, even if said code is not aware of Pixi at all.
There are still rough edges to be fixed and features to be implemented. However, big projects are already adopting it, so chances are you will be fine as well. As usual, please chat us up on our Discord server or open issues on GitHub as you find them.