ROS 2 local_setup.bash vs setup.bash: ros2: command not found
I built a small overlay workspace on top of ROS 2 Jazzy, then opened clean shells with every inherited environment variable removed and sourced a single script in each one. In the shell that sourced only the overlay’s install/local_setup.bash, ros2 pkg list failed with exit code 127 and printed bash: line 5: ros2: command not found. Reading the ament index directly in that shell found 3 packages: my 3 overlay packages and nothing else, not even rclpy. The shell that sourced only the overlay’s install/setup.bash found ros2 at /opt/ros/jazzy/bin/ros2 and listed 197 packages, including all 3 of mine. Sourcing the underlay alone (/opt/ros/jazzy/setup.bash) listed 194. All 5 runs produced the same counts.
Before running anything I wrote down a prediction for each shell (U = packages visible with only the underlay sourced, M = packages in my overlay):
| Clean shell | What was sourced | Prediction | Measured (same in all 5 runs) | AMENT_PREFIX_PATH entries |
|---|---|---|---|---|
| T0 | nothing | no ros2 (exit 127), index 0 |
no ros2, exit 127, index 0 |
0 |
| T1 | /opt/ros/jazzy/setup.bash |
U packages, 0 overlay packages | ros2 pkg list: 194 lines, 0 overlay packages |
1 |
| T2 | /tmp/ws/install/setup.bash |
U+M | ros2 pkg list: 197 lines, 3 of 3 overlay packages, rclpy present |
4 |
| T3 | /tmp/ws/install/local_setup.bash |
no ros2 (exit 127), index M |
no ros2, exit 127, index 3, rclpy absent, /opt/ros/jazzy not in AMENT_PREFIX_PATH |
3 |
| T4 | /opt/ros/jazzy/setup.bash, then /tmp/ws/install/local_setup.bash |
same package set as T2 | ros2 pkg list: 197 lines, package name set identical to T2 |
4 |
The script’s verdict line: U = 194, M = 3, name overlap 0, baseline (U+M+M)/2 = 100.0. T2’s count of 197 was above the baseline (verdict underlay_and_overlay_visible). T3’s count of 3, taken from the ament index because ros2 wasn’t available, was below it (verdict overlay_only). In T1, T2 and T4 the ros2 pkg list line count matched the direct ament index count.
The generated scripts matched the prediction too. The overlay’s install/setup.bash contained the line COLCON_CURRENT_PREFIX="/opt/ros/jazzy" plus a second COLCON_CURRENT_PREFIX= line that computes its own directory. install/local_setup.bash had no COLCON_CURRENT_PREFIX= lines and didn’t mention /opt/ros/jazzy anywhere.
Four lines of raw output from run 1, unedited:
{"kind": "generated_scripts", "setup_bash_prefix_assignments": ["COLCON_CURRENT_PREFIX=\"/opt/ros/jazzy\"", "COLCON_CURRENT_PREFIX=\"$(builtin cd \"`dirname \"${BASH_SOURCE[0]}\"`\" > /dev/null && pwd)\""], "local_setup_bash_prefix_assignments": [], "setup_bash_mentions_underlay": true, "local_setup_bash_mentions_underlay": false}
{"kind": "terminal", "terminal": "T2_overlay_setup", "sourced": ["/tmp/ws/install/setup.bash"], "source_exit_codes": [0], "ros2_path": "/opt/ros/jazzy/bin/ros2", "ros2_pkg_list_exit": 0, "ros2_pkg_list_lines": 197, "ros2_pkg_list_stderr_head": "", "index_count": 197, "probes_in_index": 3, "probes_in_pkg_list": 3, "rclpy_in_index": true, "ament_prefix_entries": 4, "underlay_in_ament_prefix_path": true, "colcon_prefix_path": "/tmp/ws/install", "shell_seconds": 0.47}
{"kind": "terminal", "terminal": "T3_overlay_local_setup", "sourced": ["/tmp/ws/install/local_setup.bash"], "source_exit_codes": [0], "ros2_path": null, "ros2_pkg_list_exit": 127, "ros2_pkg_list_lines": null, "ros2_pkg_list_stderr_head": "bash: line 5: ros2: command not found", "index_count": 3, "probes_in_index": 3, "probes_in_pkg_list": null, "rclpy_in_index": false, "ament_prefix_entries": 3, "underlay_in_ament_prefix_path": false, "colcon_prefix_path": "/tmp/ws/install", "shell_seconds": 0.05}
{"kind": "verdict", "U": 194, "M": 3, "name_overlap": 0, "baseline": 100.0, "hypothesis_underlay_included": 197, "hypothesis_overlay_only": 3, "T2": {"terminal": "T2_overlay_setup", "ros2_available": true, "count_used": 197, "count_source": "ros2_pkg_list", "above_baseline": true, "verdict": "underlay_and_overlay_visible"}, "T3": {"terminal": "T3_overlay_local_setup", "ros2_available": false, "count_used": 3, "count_source": "ament_index", "above_baseline": false, "verdict": "overlay_only"}, "T4_equals_T2": true, "pkg_list_matches_index": {"T1_underlay_setup": true, "T2_overlay_setup": true, "T4_underlay_setup_then_overlay_local_setup": true}, "total_seconds": 5.63}
Environment: Docker image ros:jazzy-ros-base pinned to ros@sha256:066420e07f60aa18262f2479981def87ebcfcec42eefb0c0c57c4a46098348ca, ROS_DISTRO=jazzy, Python 3.12.3, rclpy 7.1.12-1noble.20260902.053513, RMW rmw_fastrtps_cpp (default), no network, 1 CPU and 1 GiB of memory. Debian packages: python3-colcon-core 0.21.3+upstream-1, python3-colcon-bash 0.5.0-100, python3-colcon-ros 0.5.0+upstream-1. The overlay was 3 minimal ament_python packages (huntlab_probe_a, huntlab_probe_b, huntlab_probe_c, each with an exec dependency on rclpy), built with colcon build --parallel-workers 1 from a shell where /opt/ros/jazzy/setup.bash had been sourced. The build exited 0 in every run and took 4.11 to 4.15 s. Each “clean shell” was env -i HOME=/tmp PATH=/usr/sbin:/usr/bin:/sbin:/bin LANG=C.UTF-8 bash --noprofile --norc -c .... 5 runs, all with exit code 0.
So the answer to the common question is yes, there’s a real difference. In a terminal where nothing has been sourced yet, the overlay’s local_setup.bash adds your workspace and nothing else. The ros2 executable lives in the underlay at /opt/ros/jazzy/bin, so you get ros2: command not found, and anything your packages import from the underlay (rclpy here) is missing as well. The overlay’s setup.bash brings in the underlay first and then your workspace, which is why it works in one line.
This is for ROS 2 developers who have built a colcon workspace and are deciding what to put in ~/.bashrc, or who just opened a new terminal and got ros2: command not found after sourcing their workspace. You need to know what source and colcon build do. Everything was measured on Jazzy with bash.
What to put in .bashrc
Either of these gives a new terminal both the underlay and your overlay. In the experiment they produced the same package set (T2 and T4):
# One line: the overlay's setup.bash loads the underlay it was built against
source ~/ros2_ws/install/setup.bash
# Two lines, as in the "Creating a workspace" tutorial: underlay first, then the overlay's local_setup.bash
source /opt/ros/jazzy/setup.bash
source ~/ros2_ws/install/local_setup.bash
Replace ~/ros2_ws with your workspace path. Don’t keep only the local_setup.bash line. Delete the first line of the two-line version and you get the T3 shell above.
Some tutorials source both install/local_setup.bash and install/setup.bash of the same workspace, one after the other. The first line adds nothing: the colcon-bash template makes setup.bash source its own local_setup.bash as the last step anyway (prefix_chain.bash.em, L19-L32).
local_setup.bash is the right choice when the shell already has the underlay you want and you only need to add one more workspace on top. That’s exactly the situation in the two-line version.
Why the ROS 2 docs show both
If you followed the Jazzy beginner tutorials, you’ve seen both forms. Using colcon to build packages runs source install/setup.bash after the build. Creating a workspace opens a new terminal, sources /opt/ros/jazzy/setup.bash, and then sources install/local_setup.bash. A note in the second tutorial explains the difference (source, L343-L347):
Sourcing the
local_setupof the overlay will only add the packages available in the overlay to your environment.setupsources the overlay as well as the underlay it was created in, allowing you to utilize both workspaces.So, sourcing your main ROS 2 installation’s
setupand then theros2_wsoverlay’slocal_setup, like you just did, is the same as just sourcingros2_ws‘ssetup, because that includes the environment of its underlay.
Two terms from that note. The underlay is the environment your workspace builds on, usually the binary install at /opt/ros/jazzy, though it can be another workspace. The tutorial says the underlay “must contain the dependencies of all the packages in your overlay” (L27-L30). The overlay is your workspace, built on top of it.
The note covers the two-line case. It doesn’t spell out what happens when you drop the underlay line, which is what the experiment checked.
How setup.bash finds the underlay
Both scripts are written by colcon’s bash extension at build time, from two different templates (colcon-bash bash.py, L25-L50).
local_setup.bash comes from a template whose header reads “This script extends the environment with all packages contained in this prefix path” (prefix.bash.em, L3-L4). It runs each package’s environment hooks in that prefix and has no code that reaches up to a parent environment.
setup.bash is a chain script. When you run colcon build, colcon collects the “parent” prefixes from the build shell’s environment (colcon-core prefix_path, L60-L95). It looks at COLCON_PREFIX_PATH (colcon.py, L23-L35), and through colcon-ros at every AMENT_PREFIX_PATH entry that has a local_setup.* file (colcon-ros ament.py, L26-L66). If /opt/ros/jazzy/setup.bash was sourced when you built, /opt/ros/jazzy ends up in that list. The template then writes each parent path into setup.bash as a literal string. At source time it runs each parent’s local_setup.bash first and its own local_setup.bash last (prefix_chain.bash.em, L19-L32).
That’s the COLCON_CURRENT_PREFIX="/opt/ros/jazzy" line in the generated script above. “The underlay it was created in” in the docs means this string: the underlay that was sourced in the shell where you ran colcon build, not whatever happens to be sourced when you open a terminal later. The colcon developer docs describe setup.* the same way: it “first invokes the local_setup.<ext> files from all parent prefix paths and then invokes the sibling local_setup.<ext> file” (colcon: Environment setup).
The AMENT_PREFIX_PATH column fits this picture. colcon’s default isolated install gives each package its own prefix under install/<package>. So T3 has three entries, one per overlay package, and T2 and T4 have those three plus /opt/ros/jazzy.
The question goes back to a 2018 ROS Answers thread, “What is the difference between local_setup.bash and setup.bash”, now on Robotics Stack Exchange. The asker sourced only local_setup.bash in a new terminal and got ament: command not found. That was the ament build tool era. Today’s tools are different, but in this run on Jazzy with colcon the symptom was the same, now as ros2: command not found.
Reading the counts
The test turns the doc note into one number. If an overlay script brings in the underlay, a clean shell should see U+M packages. If it doesn’t, the shell sees M. The midpoint (U+M+M)/2 splits the two outcomes, so you don’t need to know U in advance. Here the two predictions were 197 and 3, which are 194 apart. A result near the 100.0 baseline would have been ambiguous, but neither shell came close.
ros2 pkg list fits this kind of counting. It reads share/ament_index/resource_index/packages under every AMENT_PREFIX_PATH entry, records each name once, and prints the sorted names one per line (ros2pkg list.py, L22-L24, ament_index resources.py, L96-L121). So ros2 pkg list | wc -l is the number of distinct package names your shell can see. Two consequences:
- If one of your packages has the same name as an underlay package, the count is U+M minus the number of shared names. The experiment used names that couldn’t collide (overlap 0). One collision wouldn’t change the verdict, but the count alone also can’t tell you whether your copy actually overrides the underlay’s.
- In a shell without
ros2you can’t use the command at all, which is why the script also counts the index files directly. In the three shells where both methods worked, they agreed.
T4 doesn’t show that local_setup.bash loads the underlay. In that shell the underlay came from the line before it. The verdict uses only T2 and T3, which started from nothing. T4 only checks the doc’s claim that the two-line form equals the one-line form, and the name sets did match.
One catch with setup.bash: it remembers the build-time underlay
Because the parent path is stored as a string, an overlay’s setup.bash keeps sourcing whatever underlay was active when you built it. If you switch underlays (another distro, or a different underlay workspace), rebuild the overlay from a fresh terminal where the new underlay is sourced, or the chain will still point at the old one. I worked this out from the template code. The experiment didn’t test it.
It also fits the tutorial’s advice to source the overlay in a new terminal, separate from the one you built in (L270-L271). The tutorial doesn’t give a detailed reason, and I didn’t measure one. Still, the build shell’s environment ends up in the generated script, so it’s worth keeping clean.
Check your own workspace
First see which parents your overlay’s setup.bash will load:
grep -n 'COLCON_CURRENT_PREFIX=' ~/ros2_ws/install/setup.bash
You should see your underlay path (for example /opt/ros/jazzy), plus one line that computes the script’s own directory. If the underlay path is missing or points at an install you no longer use, rebuild from a terminal where the right underlay is sourced.
Then open a shell with an empty environment, as the experiment did, and try one line at a time. I adapted these commands from the experiment’s env -i setup. I didn’t run them on a desktop install at ~/ros2_ws.
env -i HOME="$HOME" PATH=/usr/sbin:/usr/bin:/sbin:/bin LANG=C.UTF-8 bash --noprofile --norc
source ~/ros2_ws/install/local_setup.bash
command -v ros2 || echo "no ros2: this shell needs the underlay line too"
echo "$AMENT_PREFIX_PATH" | tr ':' '\n'
exit
Repeat with install/setup.bash. In a shell that has ros2, compare ros2 pkg list | wc -l with the count from a shell that sourced only /opt/ros/jazzy/setup.bash.
What this experiment doesn’t show
- An
env -ishell is a shell with nothing sourced. A real desktop terminal may already have sourced something through~/.bashrcor/etc/profile, and that changes what you’ll see. - Only bash was tested.
setup.zsh,setup.sh, the Windows scripts,--merge-installand chains with more than one underlay weren’t. - Overlay package names didn’t collide with underlay names, so overriding an underlay package wasn’t measured.
- Switching underlays after a build wasn’t measured. The rebuild advice above comes from reading the code.
- I didn’t check how
/opt/ros/jazzy/setup.bashand/opt/ros/jazzy/local_setup.bashdiffer from each other. - The code links point to the current upstream commits. I didn’t confirm that the colcon packages in the image were built from those exact commits.
- This is about the shell environment in a container. It says nothing about simulators or robot behavior.
Reproduce it
The script, the plan with predictions and the raw output of all 5 runs are at github.com/sungpyo9053/blog/experiments/ros2-local-setup-vs-setup-bash-fresh-terminal-package-count. The individual files: experiment.py, plan.md (written in Korean) and results.json. You need Docker. The image pull needs network access, but the container itself runs without network:
mkdir local-setup-check && cd local-setup-check
curl -fsSLO https://raw.githubusercontent.com/sungpyo9053/blog/main/experiments/ros2-local-setup-vs-setup-bash-fresh-terminal-package-count/experiment.py
docker run --rm --network none --cpus 1 --memory 1g -v "$PWD":/work ros@sha256:066420e07f60aa18262f2479981def87ebcfcec42eefb0c0c57c4a46098348ca \
bash -c "source /opt/ros/jazzy/setup.bash && python3 /work/experiment.py"
A successful run prints nine JSON lines. In the last one, "T2" should say underlay_and_overlay_visible and "T3" should say overlay_only. If the "kind": "build" line has a non-zero exit_code, the build failed and the script stops with exit code 1 without printing any counts.
Sources
- ROS 2 Jazzy: Creating a workspace (source at cef84a4): underlay and overlay, the
local_setupvssetupnote, sourcing in a new terminal - ROS 2 Jazzy: Using colcon to build packages
- colcon documentation: Environment setup
- colcon-bash bash.py, L25-L50, prefix_chain.bash.em, L19-L32, prefix.bash.em, L3-L4
- colcon-core prefix_path/__init__.py, L60-L95, prefix_path/colcon.py, L23-L35
- colcon-ros prefix_path/ament.py, L26-L66
- ros2cli ros2pkg list.py, L22-L24
- ament_index_python resources.py, L96-L121, search_paths.py, L21-L33
- osrf/docker_images ros:jazzy-ros-base Dockerfile: the image includes colcon