UV
uv is a modern Python package and project manager written in Rust. It is designed as a complete replacement for pip, pip-tools and venv, unifying them into a single tool. uv is significantly faster than pip because it was built with performance in mind from the ground up — it uses parallel downloading, aggressive caching, and a highly optimised dependency resolver.
The key thing to understand is that uv is not just a faster pip. It is a full project manager that manages your project dependencies declaratively in a pyproject.toml file, automatically handles virtual environments, and generates a uv.lock file to ensure reproducible environments. You normally do not need to manually create or activate a virtual environment when using the uv project workflow.
How uv differs from pip, venv, and conda
| Feature | pip + venv | conda / mamba | uv |
|---|---|---|---|
| Package installation | Yes | Yes | Yes |
| Virtual environment management | Manual | Built-in | Built-in |
| Lock file support | No (needs pip-tools) | Yes (via conda-lock) | Yes |
| Non-Python dependencies | No | Yes | No |
| Speed | Baseline | Slower than pip | 10-100x faster than pip |
| Project Structure | No | No | Yes |
Note: uv pip does exist as a subcommand and works as a near drop-in replacement for pip if you need it, but it is not the primary way uv is intended to be used (See section uv add vs uv pip).
Project workflow
This is the recommended way to use uv. It keeps your dependencies, environment, and lock file all in sync automatically.
Loading Python and uv
Load a Python module before loading uv. uv will detect the Python interpreter on your PATH and use it automatically for your project.
module load Python/3.13.5-GCCcore-14.3.0
module load uv
uv records which Python was used in the uv.lock file automatically. However, to make the intended Python version visible to collaborators and anyone reproducing your work, you should also set requires-python as an exact pin in pyproject.toml to match the module you loaded:
[project] requires-python = ">=3.11.3"
Starting a new project
module load Python/3.13.5-GCCcore-14.3.0 module load uv uv init my-project cd my-project
This creates the following structure:
my-project/ +-- .python-version +-- .venv/ <- virtual environment, created automatically by uv on first uv add or uv sync +-- README.md +-- main.py +-- pyproject.toml +-- uv.lock <- lock file, generated on first uv add or uv sync
pyproject.toml is the central configuration file. It records your project metadata and the dependencies you have declared. This is the file you and uv actively work with. When you run uv add or uv remove, uv updates this file for you.
uv.lock is generated automatically by uv the first time you add a package or run uv sync. It should never be edited by hand. It contains the exact resolved version of every package that will be installed, including all transitive dependencies, pinned to specific versions and hashes. Its purpose is reproducibility: anyone running uv sync on your project gets an identical environment. You should commit this file to version control alongside pyproject.toml.
.venv is the virtual environment directory created automatically by uv. You do not need to create or activate it manually — uv manages it for you. It is hidden by default.
In short: pyproject.toml describes what your project needs; uv.lock records exactly what was resolved from those requirements; .venv is where the packages are actually installed.
Adding dependencies
uv add numpy scipy matplotlib
This does three things at once: it adds the packages to pyproject.toml, resolves the full dependency tree, and updates uv.lock with exact pinned versions. The virtual environment (.venv) is created automatically on the first uv add or uv run.
To add a development-only dependency (for example a testing framework that is not needed when running jobs):
uv add --dev pytest
To remove a dependency:
uv remove numpy
Note: On Linux, uv add will automatically select CUDA-enabled builds for packages that support it, such as PyTorch.
uv add vs uv pip
uv provides two ways to install packages and it is important to understand the difference.
uv add is the project-aware command and the one you should use by default. It declares the package as a dependency in pyproject.toml, resolves the full dependency tree, and updates uv.lock. The package is fully tracked — you can remove it cleanly with uv remove, it will be installed on any fresh clone via uv sync, and the environment stays consistent.
uv pip install is a lower-level interface that installs directly into the .venv without touching pyproject.toml. It is similar to running plain pip install into a virtual environment. Because the package is not declared as a dependency, the next uv sync will remove it to bring the environment back in line with pyproject.toml.
uv pip can can be useful when you do not want to use a project environment and just need packages installed quickly. However, if you are working within a uv project, always use uv add instead.
Running your code
uv run main.py
uv run ensures the environment is up to date with the lock file before running your script. You do not need to manually activate the environment.
Syncing an existing project
If you clone a project that already has a uv.lock file, run:
uv sync
This installs the exact versions from the lock file into .venv. Note that uv sync also removes any packages present in the environment but not in the lock file, so the environment will exactly match the lock file state.
In job scripts, use uv sync --frozen to prevent uv from modifying the lock file:
uv sync --frozen
The reason we use --frozen is because when using uv run, uv will install dependencies based on your pyproject.toml and resolve versions as needed. This may lead to different versions of your dependencies being installed over time, especially when upstream packages release new minor or patch versions. Over time, small version changes accumulate until your production environment no longer matches what you tested. Running uv with the --frozen flag ensures that dependency resolution is strictly based on your lockfile, so uv will refuse to install or upgrade any dependency not listed in the lockfile and abort if the lockfile is missing or is out of sync with pyproject.toml.
Migrating from a requirements.txt
If you have an existing requirements.txt, you can import it into a uv project:
uv add -r requirements.txt
This adds everything from the file to pyproject.toml and generates a uv.lock.
uv and the module system are separate
uv and the cluster module system are completely independent from each other. The module system makes software available on your PATH. uv creates and manages its own isolated virtual environment in the .venv folder inside your project directory, and this is where all packages installed via uv add live. These two systems do not interact — a package loaded via module load is not visible inside the .venv, and uv has no record of it.
For this reason, we recommend sticking to one approach for managing your packages. Either use the full uv workflow where you use uv add for all your packages and uv run to execute your code, only loading Python and uv as modules. This is the recommended approach as it gives you full reproducibility through pyproject.toml and uv.lock. Alternatively, use only the cluster module system for everything without uv, as you may have done before. This is a valid approach but does not give you lock file reproducibility.
Mixing the two by loading some packages as modules and managing others with uv leads to an incomplete environment definition that is difficult to reproduce and maintain.
Using uv in a job script
Because uv run automatically syncs the environment before executing, job scripts are straightforward. Load Python first, then uv, sync once from the lock file, and run your script.
#!/bin/bash #SBATCH --job-name=uv_example #SBATCH --time=01:00:00 #SBATCH --ntasks=1 #SBATCH --cpus-per-task=4 #SBATCH --mem=8GB #SBATCH --partition=regular module load Python/3.13.5-GCCcore-14.3.0 module load uv cd my-project uv sync --frozen uv run main.py
Using uv with Jupyter notebooks
The recommended way to start Jupyter within a uv project is:
cd my-project module load Python/3.13.5-GCCcore-14.3.0 module load uv uv run --with jupyter jupyter lab
uv run --with jupyter launches Jupyter with your project's .venv already set as the active Python environment. This means any packages you installed with uv add are immediately available in the notebook — no extra configuration needed.
By default Jupyter starts at http://localhost:8888/lab. Since the server is running on a cluster node rather than your own machine, you cannot open this address directly in your browser. You need either an SSH tunnel or the web portal to connect to it. See the Hábrók Jupyter documentation for instructions on both approaches.
Installing packages from within a notebook
From within a notebook cell, use !uv add to install packages. The ! prefix tells Jupyter to run the command in the shell rather than as Python code:
!uv add seaborn
This updates both pyproject.toml and uv.lock, and the package becomes available to import immediately without restarting the kernel. This is the only method that fully tracks the dependency — uv remove works correctly and the package will be present on any fresh clone after uv sync.
Avoid using !pip install, !uv pip install, or the %pip magic from within a notebook. These all install packages temporarily into the environment without updating pyproject.toml. The packages will be removed the next time uv sync runs and will not be reproduced on a fresh clone.
If you are working with a notebook written by someone else that uses %pip and you cannot change it, you can redirect %pip to install into your .venv by running uv venv --seed before starting the server:
uv venv --seed uv run --with jupyter jupyter lab
The packages will still not be recorded in pyproject.toml or uv.lock, but they will at least install into the correct environment for the current session.
Using Jupyter without a project
If you just want a quick throwaway environment for exploratory work without setting up a full uv project, you can create a plain virtual environment, install packages with uv pip install, and start Jupyter directly:
uv venv --seed uv pip install jupyterlab uv pip install numpy matplotlib source .venv/bin/activate jupyter lab
From here you can install additional packages from within the notebook using !uv pip install or !pip install. This approach has no pyproject.toml or uv.lock, so nothing is tracked or reproducible.
Creating a kernel
If you need to install packages from within the notebook, the uv documentation recommends creating a dedicated kernel for your project. A kernel is the Python process that executes code in a notebook. When you use uv run --with jupyter, uv sets up a default kernel pointing at your .venv automatically. Creating a dedicated kernel makes this explicit and permanent, so that any packages you install from within the notebook go into the correct environment.
To create a kernel, first add ipykernel as a development dependency, then register the kernel:
uv add --dev ipykernel uv run ipython kernel install --user --env VIRTUAL_ENV $(pwd)/.venv --name=my-project
The –name flag sets the name that appears in the Jupyter kernel selector. The --user flag saves the kernel spec to your home directory so it persists across sessions. After registering, select the kernel from the dropdown when creating or opening a notebook.
The kernel spec points to the absolute path of your .venv. If you move or delete your project directory the kernel will break and should be unregistered and re-created.
This approach is also what you need when using the Hábrók web portal, since the portal starts the Jupyter server independently of uv and has no knowledge of your project environment. Registering a kernel is how you make your .venv visible to that externally-started server.
To list registered kernels:
uv run jupyter kernelspec list
To remove a kernel you no longer need:
uv run jupyter kernelspec uninstall my-project