skip to content
CloudDruid
Image by Michael Schwarzenberger from Pixabay

Reproducible ML Environment with Nix and uv

/ 4 min read

The challenge

I have been working through an ML engineering challenge and the first task was straightforward: set up a Python environment with numpy, pandas, scikit-learn, and matplotlib. I could have reached for pip install and a requirements.txt like the task suggests. But tutorials that skip production-readiness habits have always been a thorn for me. I wanted something I could come back to in six months and trust.

For me that means pinning everything: the system toolchain with Nix flakes, the Python packages with uv. Nix flakes lock down the system layer (think package.json but for compilers, shared libraries, even which version of uv you are running). uv then locks down Python packages with hashes.

If you haven’t heard of uv yet: it is from Astral, has 85k+ GitHub stars, and OpenAI acquired the team earlier this year. Still fully open source. Let’s hope the AI doesn’t hallucinate its own dependency manager into oblivion.

Why uv instead of pip

So why not just use pip? Nothing wrong with pip for installing packages. The challenge is reproducibility: making sure someone else (or future-you) gets the exact same environment. pip had no lockfile until PEP 751 arrived in 2025, and its support is still experimental and catching up. uv handles this out of the box: uv lock gives you a cross-platform lockfile with hashes, and it is fast.

Once I started using it, a few things clicked. You don’t have to manually sync your environment because uv run handles that before running anything. The lockfile is intended to be consistent across platforms, assuming compatible architectures. There is also opt-in malware scanning against OSV, which is a nice extra for supply chain peace of mind.

What’s in the box

This is all foundation work before the ML challenge itself. Not many files for a fresh project, but each one has a clear role:

FileWhat it does
flake.nixProvides uv, just, and system libraries via Nix
flake.lockPins the nixpkgs commit
pyproject.tomlLists project dependencies per PEP 621
uv.lockPins exact versions with hashes
.envrcAuto-activates the environment on cd
justfileWraps common workflows into short commands

Pinning the toolchain

Here is the flake.nix. If you are on NixOS, the NIX_LD section is the important part. I followed this guide for getting prebuilt Python wheels working. The shellHook handles the venv on first entry:

{
description = "Reproducible ML Foundations";
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
flake-utils.url = "github:numtide/flake-utils";
};
outputs = { self, nixpkgs, flake-utils }:
flake-utils.lib.eachDefaultSystem(system:
let
pkgs = nixpkgs.legacyPackages.${system};
in {
devShells.default = pkgs.mkShell {
packages = [ pkgs.uv pkgs.just ];
# NixOS doesn't follow FHS, so prebuilt Python wheels can't find
# shared libraries at their expected paths. nix-ld bridges this gap.
# Reference: https://pydevtools.com/handbook/how-to/how-to-use-uv-on-nixos/
env.NIX_LD = pkgs.stdenv.cc.bintools.dynamicLinker;
env.NIX_LD_LIBRARY_PATH = pkgs.lib.makeLibraryPath [
pkgs.stdenv.cc.cc.lib # C++ support (numpy, pandas, sklearn use it)
pkgs.zlib # compression (matplotlib, pip)
pkgs.openssl # so uv can download packages over HTTPS
pkgs.libffi # Python needs this for calling C code
pkgs.glibc # base C library everything depends on
];
shellHook = ''
# Use a named venv directory instead of the default .venv
export UV_PROJECT_ENVIRONMENT=ml-ops
if [ ! -d ml-ops ]; then
uv sync
echo "Dependencies installed from uv.lock"
fi
source ml-ops/bin/activate
'';
};
});
}

cd and go

Terminal window
use flake

One line. cd in and the environment loads. cd out and it unloads.

What I need installed

[project]
name = "reproducible-ml-foundations"
version = "0.1.0"
requires-python = ">=3.11"
dependencies = [
"numpy",
"pandas",
"scikit-learn",
"matplotlib",
]

Pretty short list. This is the standard pyproject.toml format, and uv lock turns it into a full lockfile with pinned versions and hashes for every download.

Keeping it fresh

I did not want to remember the exact flags for upgrading, auditing, and rolling back. just solves this. It is a command runner that is common in the Nix community (home-manager uses it, the NixOS & Flakes Book recommends it), and just --list makes every workflow discoverable. Here are the commands I set up for this project:

Terminal window
just --list # see available commands
just status # what is installed
just preview # what upgrades are available (read-only)
just apply-deps # upgrade Python dependencies
just apply-nix # upgrade Nix toolchain
just audit # check for known vulnerabilities via pip-audit
just rollback # revert to last committed state

The audit recipe runs pip-audit against the OSV vulnerability database. Is it overkill for a learning project? Maybe. But I would rather build the habit now than bolt it on later.

The payoff

Terminal window
~/repos/reproducible-ml-foundations via 🐍 v3.14.6 (ml-ops) via ❄️ impure (nix-shell-env)
python -c "import numpy, pandas, sklearn, matplotlib; print('ready')"
ready

And that is it. The foundations are in place. Adding a new dependency is usually as simple as uv add <package> followed by uv sync. Something like PyTorch might need extra flags for CUDA or platform-specific wheels, but the lockfile-driven approach stays the same regardless. If you are building something similar, I hope this helps you skip some of the rabbit holes I went down. Happy hacking! 😊