July 01, 2026
Part 2 of 3 · ← Part 1: I Don’t Mind My Heart Breaking — As Long As QGIS Doesn’t
This part covers everything you need to start using quv from the command line: installation, creating environments, managing packages, working with Jupyter, and keeping things reproducible.
quv is a command-line tool (and a QGIS plugin — that’s Part 3) that builds Python virtual environments designed to live next to QGIS instead of fighting it. It does two things that nothing else does together:
pip install can’t drag in a newer NumPy that pulls the rug out from under QGIS.The upshot: you can have import qgis.core and import torch in the same script, in a clean isolated environment, without laying a finger on your QGIS installation.
Under the hood, quv rides on uv — the fastest Python package manager I’ve used, and it’s not close. Environment creation takes seconds. Installs feel instant. quv itself is pure Python, stdlib only, no runtime dependencies of its own. The speed is uv’s doing; quv just knows where QGIS keeps its things.
Install uv:
# Linux / macOS
curl -LsSf https://astral.sh/uv/install.sh | sh
# Windows (PowerShell)
irm https://astral.sh/uv/install.ps1 | iex
Install quv into QGIS’s Python:
# Linux / macOS
python3 -m pip install --break-system-packages /path/to/quv-python
# Windows — open OSGeo4W Shell (comes with QGIS)
pip install C:\path\to\quv-python
The --break-system-packages flag looks alarming and isn’t. Modern Linux (PEP 668) refuses to touch a system Python without it; here you’re installing a single dependency-free tool, so it’s harmless.
On Windows, run quv setup once after install. OSGeo4W wipes PATH clean every time its shell opens, and this registers quv and uv so they’re actually there when you go looking for them.
quv create my-project
That’s the whole command. quv finds your QGIS install, reads what it depends on, creates a virtual environment, drops in a sitecustomize.py that boots the QGIS paths at startup, and writes a constraints.txt pinning everything QGIS owns.
Every environment lives in one place — ~/.local/quv-envs/ — and it’s the same place on Linux, macOS, and Windows (%USERPROFILE%\.local\quv-envs\ there). No hunting across drives, no wondering where a venv ended up. If you want to look:
# Linux / macOS
ls ~/.local/quv-envs/
# Windows
dir %USERPROFILE%\.local\quv-envs\
Now, if you’re the type who reads a menu and orders the first sensible thing — great, you’re done. But if you’re the type who reads every option thinking “well, I might need that someday,” quv has preset bundles for you too:
quv create hydro-analysis --hydrology # whitebox, pysheds, flopy
quv create ml-mapping --ml # scikit-learn, xgboost, lightgbm, statsmodels
quv create deep-earth --gpu --ai # PyTorch, torchgeo, GeoAI, LLM tooling + CUDA
quv create field-tools --lidar --rs # laspy, whitebox, xarray, rioxarray, leafmap
quv create geo-lab --geology # gempy, lasio, pyvista, simpeg
quv create timber --forestry # laspy, whitebox, rasterstats, verde
quv create dev-env --dev # cmake, ninja, cython — for the builders
And if you are, in fact, greedy:
quv create everything --ml --ai --gpu --gpu-dev --lidar --rs --hydrology --geology --forestry --dev
Nobody’s stopping you. The presets compose — quv resolves them together under the QGIS constraints and sorts out the conflicts so you don’t have to play version archaeologist. Each bundle is a curated set of packages already known to behave with QGIS.
You can also point at a specific QGIS build if you keep more than one around, or start from an existing requirements file:
quv create my-project --root /opt/qgis-ltr # use a specific QGIS build
quv create my-project --requirements requirements.txt # start from an existing list
And if Blender is in your pipeline — yes, quv handles that too:
quv create viz-env --blender-path /usr/bin/blender
Don’t let the options list intimidate you, though. You can always start plain and add packages the day you actually need them:
# From anywhere, name the environment explicitly
quv install my-project some-package
# Inside an activated shell (quv activate my-project), the name is optional
quv install some-package
The environment name is only required when quv can’t already tell which one you mean. Once you’re inside quv activate my-project, it reads that from the session and you can drop the name entirely. The presets are a convenience, not an obligation. quv will not judge you for starting small.
At this point, if you’re not at least a little impressed, I’m not sure what to tell you.
Some installs are a gamble — a package with heavy native dependencies, a --no-binary build, that one library whose README ends with “should work.” Before you roll the dice on your real environment, clone it:
quv clone my-project my-project-experiment
You get a full copy — config and installed packages. Break the clone all you like. If it works out, keep it; if it doesn’t, delete it and your original is exactly where you left it. It’s the cheapest insurance in the toolbox.
Sometimes you have tools that live outside any virtual environment — a custom GDAL build, something you installed with pipx, a set of field-survey utilities that shipped with their own installer. You want them available inside your quv environments without hardcoding paths into every script.
That’s what quv path is for.
# Add a directory to the global trusted PATH store (every activated env sees it)
quv path add ~/.local/bin
# Add a directory for one environment only
quv path add /opt/custom-gdal/bin --env geo-lab
# See what's registered
quv path list
# Remove one you no longer need
quv path remove /opt/old-tools/bin
When you activate an environment, quv prepends these in a sensible order: per-environment paths first, then global paths, then the system PATH. Your custom tools show up where you want them, without bleeding into environments where they don’t belong.
There’s one guardrail worth knowing about. Before it writes anything, quv looks at the directory you’re adding. If the path smells like it carries its own native libraries — GDAL, Qt, ESRI, GRASS, that crowd — it warns you first, because those are exactly the things that start DLL version fights with QGIS. You can still add it; it’s your call. You’ll just be making it with your eyes open.
On Windows, OSGeo4W resets PATH on every shell launch, so anything you add by hand evaporates by the next session. quv setup handles that: it reads your trusted paths and bakes them into quv.bat, so they survive.
# Install packages
quv install my-project httpx pandas scikit-learn
# Run a script inside the environment
quv run my-project python myscript.py
# Launch JupyterLab with a QGIS-aware kernel
quv jupyter my-project
Inside Jupyter, import qgis.core works. import processing works. Your packages work.
One honest note, because I’d rather you hear it from me than from a confused notebook cell: the first bare import may print a one-time “Application path not initialized” message. It’s a notice, not an error. quv exposes an init_qgis() function as a builtin in every script, REPL, and notebook — no import needed — and calling it once spins up the QGIS application context that the Processing framework and the heavier APIs expect. Run it, and the notice goes away for good.
If you’re on the Flatpak build of QGIS, quv does something quietly clever here. Flatpak seals QGIS inside a sandbox, so a normal outside-the-sandbox Python can’t see its bindings no matter how you set the paths. quv routes quv jupyter and QGIS-touching quv run calls into the sandbox for you. You run the same command as everyone else; quv figures out that it needs to go through flatpak run and does it silently. You were never supposed to notice, which is the point.
If you’re doing more than running a single script, activate the environment:
quv activate my-project
This opens a new shell with your prompt changed so you always know where you are:
(quv:my-project) user@host:~/project$
The prompt is shell-aware — it renders correctly in bash, zsh, and fish without mangling readline or history navigation. A small thing, but one less bit of terminal weirdness to explain to yourself later.
quv also sets LD_LIBRARY_PATH (Linux) or DYLD_FALLBACK_LIBRARY_PATH (macOS) to include the QGIS native library directories. That’s the reason native extensions like PyTorch and OpenCV find the right shared libraries without you configuring a thing. If you’ve ever lost an afternoon to a libgomp.so or libGL.so error after dropping a GPU package into a QGIS environment — this is the fix, and it’s automatic.
Inside this shell, you never specify the environment name. quv reads it from the session:
quv install httpx # installs into my-project
quv run python analysis.py # runs in my-project
quv jupyter # launches with the my-project kernel
quv doctor # health check on my-project
exit # back to normal
Type exit when you’re done. Your QGIS is untouched. Your environments are isolated. Life is good.
Two commands for when you’ve built up a shelf of environments and can’t remember what’s on it:
quv list # every environment, with QGIS version, Qt major, and presets
quv info my-project # the full detail on one: binding, pins, install date, packages
quv list is the quick roll call. quv info is the deep look — it also tells you whether the QGIS binding is still valid, which is the first thing worth checking when something feels off.
And when something feels properly off:
quv doctor my-project
doctor runs down the health of an environment — Python path, constraints, PROJ and GDAL data, the sitecustomize.py bootstrap, uv version drift, a bundled-GDAL check on rasterio/fiona. A healthy environment is a wall of green [OK]s. Anything else is a real problem, and doctor names it instead of making you guess.
This is the scenario most tools pretend doesn’t exist. You update QGIS — new version, new Python build, new GDAL — and your quv environments are now pointing at paths that quietly no longer exist.
quv upgrade my-project
quv re-reads the new QGIS install, shows you exactly what moved (Python version, GDAL version, NumPy pin, and so on), rewrites the environment configuration, and regenerates the bootstrap. If QGIS changed its Python version outright — which happens on major updates — add --rebuild and quv snapshots your packages, recreates the venv from scratch on the new Python, and reinstalls everything. Same environment from your seat; rewired underneath.
And when it’s just your own packages that need freshening:
quv update my-project # everything, within the QGIS constraints
quv update my-project pandas # or only what you name
No manual path-editing. No recreating an environment from memory. No broken imports on Monday morning.
quv lock my-project
This writes a lock file — an exact snapshot of every package and version in the environment. Commit it, share it, hand it to a colleague:
quv sync my-project
They get the identical environment, package for package. Handy for team projects, for deployment, or for the version of you six months from now who has no memory of which scikit-learn this was built against.
If you write your code in VS Code, PyCharm, Cursor, or Zed, those editors find interpreters by looking for a .venv folder in your project. quv environments live off in ~/.local/quv-envs/, so your editor can’t see them — until you leave it a signpost:
cd ~/code/my-plugin
quv link my-project # drops a .venv link pointing at the env
Your editor picks it up as if the environment were sitting right there in the project, with PyQGIS already on the path. quv link --unlink removes it when you’re done.
quv has a set of tools aimed squarely at people building for QGIS.
Scaffold a new plugin:
quv new plugin "My Plugin" --type dockwidget --env my-project --description "Does awesome things"
You get a complete, standards-compliant QGIS plugin — proper __init__.py, metadata.txt, main plugin class, and your choice of shape (toolbar, menu, dockwidget, or processing). No pb_tool, no resources.qrc, no pyrcc5 — icons are plain PNGs. The generated code is QGIS 3.x and 4.x compatible out of the box. Add --install and quv copies it straight into your QGIS profile so it’s ready to load.
Scaffold a standalone project — for batch scripts and services that import qgis.core but aren’t plugins:
quv new project raster-pipeline --env my-project
Validate before you ship:
quv plugin validate ./my-plugin
Static analysis catches the errors that otherwise only show up at runtime — a missing classFactory, the wrong pushMessage level (the one that crashes on QGIS 4.x), a bare import PyQt5 where qgis.PyQt belongs. Wire it into CI and broken plugins never reach main.
Package for distribution:
quv plugin package ./my-plugin
Produces a zip in the exact shape QGIS wants for “Install from ZIP” — named correctly, structured correctly, validation run first.
| The old way | With quv |
|---|---|
pip install into QGIS Python | quv install <env> <pkg> |
| QGIS broken, no idea why | QGIS-owned packages pinned and protected by default |
import qgis.core fails in a venv | Works automatically in every quv environment |
Manually set PYTHONPATH per machine | sitecustomize.py handles it at creation time |
libgomp.so errors with PyTorch or OpenCV | LD_LIBRARY_PATH set automatically on activation |
| PyQGIS invisible inside the Flatpak sandbox | Calls auto-routed into the sandbox for you |
| Custom tool paths lost after the session ends | quv path add — trusted paths survive every session |
| QGIS updates, environment breaks | quv upgrade re-links in seconds |
| “It works on my machine” | quv lock + quv sync — exact reproduction |
If the command line isn’t your natural habitat, the next part covers the quv QGIS plugin — the same workflow, without ever leaving the application.
Continue to Part 3: The Plugin →