The article remember the first ROS 2 build. It was 2018, the tooling was a little rough, and It a weekend fighting colcon before It gots a tiny package to compile. The instructions were scattered, half-written, and didn't explain why any step existed. If you're starting today, you shouldn't repeat that experience. This is the walkthrough Worth noting It would had — opinionated, minimal, and tested on the current LTS as of 2026.
Why This Matters
The robotics industry has shifted from "weekend project with one Arduino" to "production fleet with hundreds of nodes," and the build system is where that shift lives or dies. A sloppy ROS 2 build system will lose you hours every week to broken symlinks, stale caches, and mysterious ABI errors. A clean one is invisible — you push code, it compiles, tests run, you ship. The difference is structural: it comes from understanding workspaces, packages, and the colcon toolchain end-to-end.
There's also a real-world economic angle. Every major robotics company It has consulted for has at least one engineer whose entire job is build system maintenance. Getting the foundations right early saves you a hire later. Documentation and common practice have companies spend half a million dollars a year on FTE time just to keep their build system from rotting, and another half million on CI infrastructure to keep it from regressing. A well-thought-out workspace pays for itself within months.
Beyond cost, there's a velocity angle. When your build is fast and predictable, developers iterate. When it's slow and flaky, they batch changes. That batch-then-pray cycle is what kills projects: a developer saves three days of work, merges it, and the integration breakage takes another week to untangle. Good build systems let people make small, frequent commits; bad ones push everyone toward bigger, riskier ones.
There's also a domain-specific reason: hardware integration. In robotics, you often can't test your changes against the real robot until you've physically integrated them. A bad build means a long feedback loop — code, compile, flash, boot, debug — where each cycle takes 20 minutes instead of 90 seconds. Multiply that by the ten debug cycles you need for any nontrivial feature, and a slow build costs you a full afternoon per change.
The Core Idea
ROS 2 is structured around three layers: the workspace, the package, and the node. A workspace is a directory (typically ~/ros2_ws) where your packages live. Each package is a self-contained directory with a manifest (package.xml), a build recipe (CMakeLists.txt, setup.py, or pyproject.toml), and your source code. A node is a single executable in a package — the actual running program that publishes, subscribes, serves, or acts.
The build tool is colcon. It walks the workspace, resolves dependencies by reading each package's manifest, and produces a unified install directory (install/) plus a cache of build artifacts (build/). Critically, colcon understands both C++ and Python packages in the same workspace, which ROS 1's catkin made awkward. It also supports a merge-install mode that produces a single combined install tree, ideal for deployment images.
A canonical Python package layout looks like this:
my_robot_pkg/
├── package.xml
├── setup.py
├── resource/
│ └── my_robot_pkg
├── my_robot_pkg/
│ ├── __init__.py
│ ├── talker.py
│ └── listener.py
└── launch/
└── demo.launch.py
The package.xml declares dependencies (rclpy, std_msgs, etc.), the license, and the maintainers. The setup.py declares the entry points that ros2 run will eventually invoke. The double directory is intentional — it lets Python find the package even when launched from anywhere on the system.
If you write C++ instead, the layout is similar but you add a CMakeLists.txt and write source in src/:
my_robot_cpp_pkg/
├── package.xml
├── CMakeLists.txt
├── src/
│ └── talker.cpp
└── launch/
└── demo.launch.py
The key insight for newcomers: ROS 2 is modular at every level. A package is the unit of reuse; a workspace is the unit of integration; a node is the unit of execution. Get those three scales right and you have the foundation.
There's a four-step mental model for any ROS 2 build:
- Source the underlay.
source /opt/ros/jazzy/setup.bashbrings the base install into your shell. - Build the workspace.
colcon build --symlink-installproducesinstall/and overlays the underlay. - Source the overlay.
source install/setup.bashmakes your packages available. - Run or launch.
ros2 run my_pkg my_nodefor a single node,ros2 launch my_pkg demo.launch.pyfor a full system.
Each of those steps gives you something tangible. Step 1 gives you ROS 2 itself. Step 2 gives you your code built. Step 3 makes it runnable. Step 4 proves it works.
A few practical tips that make life with colcon easier:
- Use
--symlink-installin development. It installs Python source as symlinks, so edits are picked up live without rebuilds. For C++, you still rebuild, but installation is fast. - Use
--packages-selectto iterate on one package.colcon build --packages-select my_pkgskips everything else. Pair it with--event-handlers console_direct+for live output. - Use
--merge-installfor deployment images. The resultinginstall/directory is portable and self-contained. - Use
--cmake-args -DCMAKE_BUILD_TYPE=Releasefor production. The defaultDebugbuild is 5–10× slower at runtime. - Use a
.colcon_defaultsfile in your workspace to persist build options across builds. Saves keystrokes and prevents "works on the machine" issues.
There are two more layers of the build system worth understanding as your project grows. Rosdep resolves system dependencies declared in package.xml. When you colcon build, rosdep walks the manifests, sees rclpy, looks up its Ubuntu package name (python3-rclpy), and ensures it's installed. If you skip rosdep, you'll hit mysterious import errors. Underlay overlays are the mechanism for chaining workspaces: you can have a base ROS 2 install, overlay your company-internal packages on top, and overlay a project-specific workspace on top of that. The combined environment is the union of all three.
A Concrete Example
Let's build a small two-node application: a talker that publishes a string on /chatter, and a listener that logs what it hears. We'll do it as a Python package so you don't need a C++ toolchain.
First, create the workspace and package skeleton:
mkdir -p ~/ros2_ws/src
cd ~/ros2_ws/src
ros2 pkg create --build-type ament_python my_chat_pkg \
--dependencies rclpy std_msgs
That generates the full directory layout. Now create the talker:
# my_chat_pkg/my_chat_pkg/talker.py
import rclpy
from rclpy.node import Node
from std_msgs.msg import String
class Talker(Node):
def __init__(self) -> None:
super().__init__('talker')
self.publisher_ = self.create_publisher(String, 'chatter', 10)
self.timer = self.create_timer(0.5, self._publish)
self._i = 0
def _publish(self) -> None:
msg = String()
msg.data = f'Hello, ROS 2 world #{self._i}'
self.publisher_.publish(msg)
self.get_logger().info(msg.data)
self._i += 1
def main(args=None):
rclpy.init(args=args)
node = Talker()
try:
rclpy.spin(node)
except KeyboardInterrupt:
pass
finally:
node.destroy_node()
rclpy.shutdown()
And the listener:
# my_chat_pkg/my_chat_pkg/listener.py
import rclpy
from rclpy.node import Node
from std_msgs.msg import String
class Listener(Node):
def __init__(self) -> None:
super().__init__('listener')
self.subscription = self.create_subscription(
String, 'chatter', self._on_msg, 10,
)
def _on_msg(self, msg: String) -> None:
self.get_logger().info(f'I heard: "{msg.data}"')
def main(args=None):
rclpy.init(args=args)
node = Listener()
try:
rclpy.spin(node)
except KeyboardInterrupt:
pass
finally:
node.destroy_node()
rclpy.shutdown()
Register them as entry points in setup.py:
entry_points={
'console_scripts': [
'talker = my_chat_pkg.talker:main',
'listener = my_chat_pkg.listener:main',
],
},
Build and run:
cd ~/ros2_ws
colcon build --symlink-install --packages-select my_chat_pkg
source install/setup.bash
# Terminal 1
ros2 run my_chat_pkg talker
# Terminal 2
ros2 run my_chat_pkg listener
You should see the listener logging I heard: "Hello, ROS 2 world #N" twice a second. That's a full ROS 2 application, end-to-end, in about thirty lines.
To make it a launch file:
# my_chat_pkg/launch/demo.launch.py
from launch import LaunchDescription
from launch_ros.actions import Node
def generate_launch_description() -> LaunchDescription:
return LaunchDescription([
Node(package='my_chat_pkg', executable='talker'),
Node(package='my_chat_pkg', executable='listener'),
])
Run it with:
ros2 launch my_chat_pkg demo.launch.py
Now, suppose you want to add parameters. Edit the talker to declare them:
class Talker(Node):
def __init__(self) -> None:
super().__init__('talker')
self.declare_parameter('rate_hz', 2.0)
self.declare_parameter('prefix', 'Hello')
rate = self.get_parameter('rate_hz').value
prefix = self.get_parameter('prefix').value
self.publisher_ = self.create_publisher(String, 'chatter', 10)
self.timer = self.create_timer(1.0 / rate, self._publish)
self._prefix = prefix
self._i = 0
def _publish(self) -> None:
msg = String()
msg.data = f'{self._prefix}, ROS 2 world #{self._i}'
self.publisher_.publish(msg)
self.get_logger().info(msg.data)
self._i += 1
And a YAML parameter file config/params.yaml:
talker:
ros__parameters:
rate_hz: 5.0
prefix: 'Greetings'
Update the launch file to load it:
from launch import LaunchDescription
from launch.substitutions import PathJoinSubstitution
from launch_ros.actions import Node
from launch_ros.substitutions import FindPackageShare
def generate_launch_description() -> LaunchDescription:
params_file = PathJoinSubstitution([
FindPackageShare('my_chat_pkg'),
'config',
'params.yaml',
])
return LaunchDescription([
Node(
package='my_chat_pkg',
executable='talker',
name='talker',
parameters=[params_file],
),
Node(
package='my_chat_pkg',
executable='listener',
name='listener',
),
])
Now ros2 launch brings up the talker with the parameter file applied. You can override at runtime with ros2 param set /talker rate_hz 10.0. The full pattern — declare parameters, load them from a file, allow runtime overrides — is the foundation of every production ROS 2 system.
Common Pitfalls
Forgetting to source the underlay. You'll get cryptic
colcon: command not founderrors. Add the source line to your~/.bashrc.Building without
--symlink-installin development. Without it, every code change forces a full rebuild. With it, Python changes are picked up live.Multiple workspaces competing. Don't
sourcetwo overlays on top of each other; the second one replaces the first in your PATH. Use--merge-installif you really need to.Missing entry points in
setup.py. Ifros2 run my_pkg my_nodereturns "executable not found," your console scripts block is wrong.Forgetting
ament_pythonas a build type.package.xmldeclares<build_type>ament_python</build_type>, andsetup.pyshould haveget_package_share_pathand theament_pythonbuild helpers.Not running
colcon build --packages-selectduring iteration. Building the whole workspace on every change is a waste of minutes.Hard-coding paths in launch files. Use
get_package_share_directory()so your launch file is portable.Mixing
ament_cmakeandament_pythonwithout understanding. Most packages pick one. Hybrid packages are possible but tricky; if you need them, study a known example likepcl_ros.Committing
build/andinstall/to git. These are generated; they're in.gitignorefor a reason. Same for__pycache__,.colcon, and any build cache files.Skipping dependency declaration. Every package you import from must be in your
package.xmlandsetup.pyinstall_requires. Otherwise CI breaks the moment a teammate runs it.
When to Use This (And When Not To)
The pattern above works for Python packages in ROS 2, which covers most AI/ML, scripting, and integration work. C++ packages are the right choice when you need maximum performance (real-time control loops, vision pipelines), strict memory control, or ABI stability across nodes. Hybrid workspaces with both Python and C++ packages are common and well-supported.
If you're building a tiny embedded node, you probably want micro-ROS instead of full ROS 2 — it brings a subset of the API to microcontrollers. If you're running a single-machine, single-threaded robot with no perception stack, you might even be fine with raw DDS or ZeroMQ — but you'll be swimming upstream against the ecosystem.
One final consideration: as your project grows past a handful of packages, you'll want to adopt a release-engineering workflow. That means a CI pipeline that builds the workspace on every PR, runs colcon test against all packages, lints the code, and produces a signed Docker image that ships to your fleet. ROS 2 is structured to support this from day one, but only if you treat your build system as a first-class artifact, not an afterthought.
A Brief Note on CI for ROS 2
Once you have a workspace that builds locally, the next step is to make it build reproducibly on CI. GitHub Actions, GitLab CI, and Jenkins all work; the pattern is the same. The CI job installs ROS 2, sets up a workspace, builds the packages under test, runs colcon test, and reports results.
A minimal GitHub Actions workflow for ROS 2:
name: ROS 2 CI
on: [push, pull_request]
jobs:
build:
runs-on: ubuntu-22.04
container:
image: ros:jazzy-ros-base
steps:
- uses: actions/checkout@v4
- name: Install dependencies
run: |
apt-get update
rosdep update
rosdep install --from-paths src --ignore-src -r -y
- name: Build
run: colcon build --symlink-install --cmake-args -DCMAKE_BUILD_TYPE=Release
- name: Test
run: colcon test --event-handlers console_direct+
- name: Report
if: always()
run: colcon test-result --all
That workflow takes about 5–10 minutes for a small project, 30 minutes for a large one. It catches dependency issues, build breaks, and test regressions before they reach production. Without it, you're relying on individual developer machines, which is a recipe for "works on the machine" bugs.
For larger projects, you can split the build across multiple CI jobs by package, parallelize test execution with pytest-xdist, and add a separate job that builds the deployment Docker image. The pattern scales; you just need to invest the time.
Wrapping Up
A ROS 2 application is a structured workspace of packages, each composed of nodes. The build tool is colcon. The mental model is layered: underlay, workspace, package, node, launch. Master these and you have a foundation that scales from a 30-line demo to a multi-robot fleet.
Concrete next step: take this talker/listener code, add a parameter for the talker's frequency, and load it via a YAML parameter file in the launch file. Once that works, you've internalized the most important ROS 2 patterns. From there, try adding a colcon test --packages-select my_chat_pkg step to your workflow and write a simple pytest that spins up the listener and asserts on a published message.
Further Reading
Hermes Smith
