Uv on ROS 2: a field report on workspace-level virtual environments — five failure modes and minimal colcon/ament proposals

TL;DR: A workspace-level uv-managed venv works on stock apt-installed ROS 2 — including a PyTorch+CUDA node — but we hit five reproducible failure modes on the way (verified on Jazzy; none of the mechanisms are Jazzy-specific). Key measurement: the known shebang workaround ([build_scripts] executable = /usr/bin/env python3) does not cover --symlink-install, so when colcon is launched from the system Python there is currently no complete workaround. Below are four minimal change proposals for colcon/ament — all opt-in, none fixing the venv’s location or name, with no behavior change for workspaces that do not use a venv.

Background

PEP 668 disabled pip install into the system Python on Ubuntu 24.04, and deep-learning robotics often needs exact version pins and custom package indexes (e.g. torch==2.6.0+cu124) that package.xml/rosdep currently has no way to declare. A per-workspace virtual environment with pyproject.toml and a lockfile — managed here with uv — is one practical answer. In Letting Python Be Python, the idea that workspaces could become venvs was raised, along with the question of what it would take to get there; Status of Colcon building “standards-based” Python packages covers the related build-tool work. This post adds empirical data to that discussion: we migrated a real robot stack to uv while keeping colcon, ros2 run, and ros2 launch in use, and recorded what broke and why.

What works and what breaks

With a venv created by uv venv --system-site-packages from the distro interpreter, and python-preference = "only-system" set in the [tool.uv] section of pyproject.toml, everything builds and a torch+CUDA inference node runs on the venv’s Python, with lockfile reproducibility and custom wheel indexes.

Setup: Ubuntu 24.04 / apt Jazzy / Python 3.12.3 / setuptools 68.1.2 / uv 0.11.28, and pyproject.toml

Workspace: 3 × ament_cmake, 1 × ament_python, one inference node using torch==2.6.0+cu124. Disposable Docker containers; venv/lockfile/uv cache on bind mounts. Shells source the ROS environment first, then activate the venv.

[project]
requires-python = "==3.12.*"          # pin to the distro interpreter
dependencies = [
    "torch==2.6.0",
    "numpy>=1.26,<2",                 # protect cv_bridge (numpy 1.x C ABI)
]

[tool.uv]
package = false                        # env definition only
python-preference = "only-system"     # never substitute uv-managed CPython

[tool.uv.sources]
torch = [{ index = "pytorch-cu124" }]

[[tool.uv.index]]
name = "pytorch-cu124"
url = "https://download.pytorch.org/whl/cu124"
explicit = true

Along the way we hit five reproducible failure modes. All of them can be worked around, but the workarounds are not covered by official documentation, so they are easy to rediscover independently:

# Failure mode Cause Current workaround
1 Every shell needs two setup steps (source install/setup.bash and venv activation), in order The ROS environment and the venv have no knowledge of each other Hand-written shell setup per project
2 colcon treats directories inside the venv as packages during discovery Package discovery descends into every subdirectory touch .venv/COLCON_IGNORE (documented)
3 ros2 run executes ament_python nodes with the system interpreter even while a venv is active colcon runs setup.py with its own sys.executable; setuptools writes that interpreter into console-script shebangs Incomplete — see next section (ros2/ros2#1094, open since 2021)
4 numpy 2.x in the venv breaks apt-built extensions (cv_bridge) at import Jazzy binaries are built against numpy 1.26’s C ABI Pin numpy<2 in the workspace
5 uv provisions its own standalone CPython, which mismatches distro-built C extensions uv’s default python-preference python-preference = "only-system" in pyproject.toml ([tool.uv])

The remaining gap

Four of the five have complete workarounds; #3 does not. A known mitigation is [build_scripts] executable = /usr/bin/env python3 in setup.cfg (mechanism related to colcon-core#183, reported in ros2/ros2#1094). We measured it on Jazzy:

  • Regular colcon build: works — scripts get env shebangs and resolve to the active venv.
  • colcon build --symlink-install: not applied — the develop/editable code path keeps #!/usr/bin/python3, so the mode commonly used during development is not covered.
  • Launching colcon from the venv itself — .venv/bin/python -m colcon build — covers both modes (with --system-site-packages, the apt-installed colcon is importable from the venv, so nothing extra needs to be installed). The limitation: the venv’s absolute path is written into the generated shebangs, so the result does not survive venv recreation and install/ is not relocatable.

Bottom line: when colcon is launched from the system Python — the common configuration in tutorials and CI — there is currently no complete workaround.

Proposed changes

One design principle for all four: opt-in, no fixed venv location or name, and no behavior change for workspaces that do not involve a venv.

  • P1 — discovery: skip any directory containing pyvenv.cfg (the PEP 405 marker every venv has) during package discovery — an automatic COLCON_IGNORE for venvs of any name, in any location.
  • P2 — shebangs: an option to emit #!/usr/bin/env python3 shebangs on both the install and the develop (--symlink-install) code paths. The setup.cfg mitigation covers only the install path and has to be repeated in every package; an option at the build-tool level would cover a whole workspace at once. Where no venv is active, env python3 resolves to /usr/bin/python3 as before.
  • P3 — activation: record the path of the interpreter colcon built with under install/, and let setup.bash read it and, if that interpreter belongs to a venv, activate it (with an opt-out environment variable). This is a minimal mechanism for the “workspaces as venvs” idea from the threads above, and it leaves the venv’s location entirely up to the user.

Relation to existing work

  • colcon-uv manages Python dependencies per package, installed during colcon build. This post focuses on one environment and one lockfile per workspace; the two granularities address different needs (per-package isolation vs. one shared environment for a whole launch graph) and can coexist.
  • Pixi as a co-official installation method concerns how ROS itself is installed. The scope here is different and does not compete with it: keeping the standard apt installation and making the Python layer of one workspace reproducible.
  • ros-python-wheels distributes ROS client libraries as pip-installable wheels. The direction here is complementary: using pip/uv-managed dependencies inside a standard, apt-based ROS workspace.
  • A similar uv setup (--system-site-packages + lockfile) has been shared in Status of Colcon building “standards-based” Python packages, with nodes started directly through python. The measurements above cover the case where colcon, ros2 run, and ros2 launch stay in use.

Open questions

  1. For P1: would a package-identification extension in colcon-core, modeled on the existing COLCON_IGNORE one, be an acceptable shape — or would this fit better as a separately distributed extension package?
  2. For P2, which layer would be better suited to handle the develop-path shebang — colcon-core, or the setuptools develop machinery?
  3. For those running workspace-level venvs with colcon in CI or on production robots: which failure modes are missing from the list above (overlays, cross-compilation, non-Ubuntu platforms)?
6 Likes

Thanks for putting in the effort!

In ROS workspaces, numpy should be exclusively installed via the python3-numpy rosdep, shouldn’t it? That, in connection with system-site-packages shouldn’t lead to uv installing numpy 2, or does it? I know the Python “packaging” tools can be quite dumb and news-eager at times…

I’d expect the workflow to be first install all apt-installable deps via rosdep, and then either run rosdep for pip packages, or uv sync the rest of the Python deps. I guess the traditional rosdep workflow doesn’t work in this kind of batches, making it random in which order will the packaging tools be called, right?

2 Likes

What about ignoring all hidden and dot-starting dirs? I don’t see a valid use-case with ROS packages in hidden dirs.

Is there an actual reason why does the system interpreter get hardcoded there? Or does it seem more like an omission?

2 Likes

Thank you, and sorry for the late reply.

In ROS workspaces, numpy should be exclusively installed via the python3-numpy rosdep, shouldn’t it? That, in connection with system-site-packages shouldn’t lead to uv installing numpy 2, or does it?

It does get installed, even with --system-site-packages. uv does not take system site-packages into account during resolution and installation. In my measurements the flag only affected runtime visibility: at runtime the venv sees the system numpy 1.26.4, yet uv pip install --dry-run numpy reports it would install its own 2.5.1.

A quick demonstration on Ubuntu 24.04 (apt numpy 1.26.4 installed):

$ uv venv --python /usr/bin/python3 --system-site-packages .venv
$ .venv/bin/python -c "import numpy; print(numpy.__version__)"
1.26.4                    # runtime sees the system copy
$ uv pip install --dry-run numpy
 + numpy==2.5.1           # ...but uv would still install its own copy

I agree with the workflow. As for the order, it does not change the outcome either way: the venv’s site-packages comes before the system’s on sys.path, so whichever runs first, the venv’s numpy ends up shadowing the system one. (When import numpy runs, Python searches the directories on sys.path from front to back, uses the first numpy it finds, and never looks further.)
Since PEP 405 specifies this venv-first ordering, it seems hard to solve cleanly…

What I ended up doing is pinning the versions of packages I knew I have installed via APT. The pip wouldn’t install them again:

pip install great_library numpy==1.26.4 scipy=1.11.4

However, that is a fully manual thing I haven’t figured how to automate…

What about ignoring all hidden and dot-starting dirs? I don’t see a valid use-case with ROS packages in hidden dirs.

I checked, and this seems to be already the case: hidden (dot-named) directories are skipped. I could not find this in the documentation, but it has been in the code since the first release of colcon-recursive-crawl: the crawl filters out subdirectories starting with a dot (recursive_crawl.py). I also confirmed it empirically: a package manifest under .venv/ is never visited, while the same manifest under venv/ is.

So, strictly speaking, failure mode #2 in my post was not accurate: .venv is already excluded, and only venvs with non-hidden names (venv/ etc.) get visited. I had carried the COLCON_IGNORE step over from the official guide, which uses touch ./venv/COLCON_IGNORE with a non-hidden venv, and assumed it applied to .venv as well… Making sure you're not a bot!

Since uv creates .venv by default, a typical uv workflow does not actually hit this failure mode. I just re-checked, and touch COLCON_IGNORE was indeed unnecessary.

So I no longer think P1 is needed. For non-uv setups, giving the venv a hidden name, or placing COLCON_IGNORE manually otherwise, seems sufficient.

Is there an actual reason why does the system interpreter get hardcoded there? Or does it seem more like an omission?

The hardcoding appears to be deliberate, longstanding distutils/setuptools behavior rather than a ROS decision. The distutils documentation states that if a script’s first line starts with #! and contains “python”, “the Distutils will adjust the first line to refer to the current interpreter location”, with --executable as an explicit override 2. Writing the Setup Script — Python 3.11.15 documentation.

Given that shebangs are hardcoded this way, I took that to be the convention to follow, which is why P2 proposed hardcoding #!/usr/bin/env python3 instead, as an opt-in option.

Yeah me too…
it’s hard to automate this process…

As a side note: I recently discussed this topic with several ROS developers in Japan, and “what about Pixi?” came up quite often. This is somewhat off-topic for this thread, but it made me think that, instead of working hard to make uv fit, moving to a Pixi-based setup may also be a direction worth considering…

Oh, you mean “completely drop rosdep and package.xml dependencies for workspace installation”? :slight_smile:

One thing @cottsay and I discussed with the prefix team at ROSCon last year was the possibility of making a supported rosdep platform, such that a rosdep install would source packages via pixi versus your native platform package manager (or lack of native platform package manager if you are on macOS/Windows).

Could still be an interesting avenue, I think both ends just lacked cycles to work on it. It would effectively be building on top of the robostack work that is already well-adopted.

1 Like

It would be great. This is one of my last remaining objections against pixi :slight_smile:

Maybe if I get to the end of the week with some tokens to spare, it could be a fun experiment… I just have too long of a list of “fun” experiments at this point and unfortunately more LLM tokens than brain tokens left.

~mc

honestly, I do not think pixi is appropriate as a final solution instead of support for something like uv.
because I’m on arch linux, I previously used pixi for some time, however I had so many issues with it that using nix-ros-overlay ended up being easier for me

I have also noticed that building python packages takes like an order of magnitude longer than building C++ packages, if nothing has changed (even when doing a symlink install!). I suspect this has something to do with python having to zip and then un-zip everything.

Thank you for all the feedback.

While there is an approach to enabling rosdep in Pixi, it made me realise that the approach of supporting uv in ROS 2 also has its merits.

If ROS 2 is to support uv, I now think the only essential remaining item is P2:

P2 — shebangs: an option to emit #!/usr/bin/env python3 shebangs on both the install and the develop (--symlink-install) code paths. The setup.cfg mitigation covers only the install path and has to be repeated in every package; an option at the build-tool level would cover a whole workspace at once. Where no venv is active, env python3 resolves to /usr/bin/python3 as before.

P3 can, at worst, be hand-written, but the P2 problem cannot be solved without changing the implementation, as measured above.

Concretely, I believe the change would go into colcon-core, in colcon-core/colcon_core/task/python/build.py at master · colcon/colcon-core · GitHub.

After that, a uv workflow on ROS 2 would look like this:

# once per workspace
uv venv --system-site-packages        # .venv; already invisible to package discovery
uv sync --frozen                      # Python deps, reproducible via uv.lock
rosdep install --from-paths src -y    # apt deps as usual

# per shell (or once in your shell profile)
source /opt/ros/<distro>/setup.bash
source .venv/bin/activate

# stock colcon from the system Python; no tricks needed
colcon build --symlink-install        # with the new shebang option
ros2 run <pkg> <node>                 # the node runs on the active venv

What do you think?

We’re evaluating Pixi at the moment. But instead of specifying every dependency again, there is also an option to point to every package.xml. It will then use the package.xml dependencies.

(Although duplication moves to specifying each package.xml which colcon can already list)

Thanks for the many mentions of Pixi. We are determined to make it an awesome tool for ROS development.

From experience I can say that building out ROS distributions for multiple OS’s is not so simple :slight_smile: In RoboStack we would definitely be very happy about more contributors, and rosdep support would be very awesome!

I’d love to hear about the problems you encountered with Pixi! Did you open any issues?

2 Likes

there were several, but the one that finally pushed me to switch was that arch updated glibc but robostack was still building against the older glibc version. after waiting over a week for the packages to be rebuilt against the newer version, I just moved to nix