====== UV ======
**[[https://docs.astral.sh/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|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 [[habrok:examples:jupyter|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 [[https://portal.hb.hpc.rug.nl|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
===== Further reading =====
* [[https://docs.astral.sh/uv/|uv documentation]]
* [[https://docs.astral.sh/uv/guides/projects/|uv projects guide]]
* [[https://docs.astral.sh/uv/guides/integration/jupyter/|uv and jupyter guide]]
* [[https://wiki.hpc.rug.nl/habrok/examples/python|Hábrók Python documentation]]