ROS 2 is a message-passing middleware, and everything you build on it reduces to three primitives: processes that compute (nodes), named channels that carry data (topics), and typed payloads (messages). This guide installs the current LTS distribution on Ubuntu, verifies the install, and builds a working sensor pipeline in rclpy. You also get the CLI commands to inspect it and the QoS settings that decide whether data arrives.
Quick Takeaways
- Install: Use ROS 2 Lyrical Luth (an LTS release supported until May 2031) on Ubuntu 26.04, or Jazzy Jalisco on Ubuntu 24.04 (supported until May 2029).
- Nodes are single-purpose executables. Topics are many-to-many, anonymous publish/subscribe channels.
- Messages are typed
.msgstructures serialized with CDR over DDS. Useros2 interface showto read any definition. - QoS mismatch is the most common reason a subscriber receives nothing. Match reliability and durability on both ends.
Choosing a ROS 2 Distribution
Pick the distribution from your OS, not from preference. Binary packages exist only for Tier 1 platforms.
| Distribution | Type | Ubuntu Base | Support Ends | Use Case |
|---|---|---|---|---|
| Lyrical Luth | LTS | 26.04 (Resolute) | May 2031 | New projects |
| Jazzy Jalisco | LTS | 24.04 (Noble) | May 2029 | Existing 24.04 fleets |
| Humble Hawksbill | LTS | 22.04 (Jammy) | May 2027 | Legacy maintenance |
| Kilted Kaiju | Non-LTS | 24.04 (Noble) | Dec 2026 | Avoid for new work |
Lyrical binaries target Ubuntu 26.04 (amd64/aarch64), RHEL 10 (amd64), and Windows 11 (amd64). The commands below use lyrical. On Ubuntu 24.04, replace lyrical with jazzy; everything else is identical.
Some ecosystem packages lag a new release. Around launch, community reports noted that Nav2, slam-toolbox, and cartographer-ros had not yet been released for Lyrical. Check the package index for your distro before committing a navigation project to a fresh release.
Installing ROS 2 on Ubuntu
Step 1: Locale and repositories
# ROS 2 requires a UTF-8 locale
locale # confirm LANG=en_US.UTF-8 or C.UTF-8
sudo apt install software-properties-common -y
sudo add-apt-repository universe
sudo apt update && sudo apt install curl -y
Step 2: Add the ROS 2 apt source
# Fetch the latest ros-apt-source release tag
export ROS_APT_SOURCE_VERSION=$(curl -s https://api.github.com/repos/ros-infrastructure/ros-apt-source/releases/latest | grep -F "tag_name" | awk -F\" '{print $4}')
# Download the .deb matching your Ubuntu codename (resolute on 26.04, noble on 24.04)
curl -L -o /tmp/ros2-apt-source.deb \
"https://github.com/ros-infrastructure/ros-apt-source/releases/download/${ROS_APT_SOURCE_VERSION}/ros2-apt-source_${ROS_APT_SOURCE_VERSION}.$(. /etc/os-release && echo ${UBUNTU_CODENAME:-${VERSION_CODENAME}})_all.deb"
sudo dpkg -i /tmp/ros2-apt-source.deb
This package installs both the repository definition and the GPG key.
Step 3: Install the packages
sudo apt update && sudo apt upgrade -y
# Desktop = ROS core + RViz + demos + tutorials
sudo apt install ros-lyrical-desktop -y
# colcon, rosdep, vcstool and other build tooling
sudo apt install ros-dev-tools -y
On a headless robot computer (Raspberry Pi, Jetson), install ros-lyrical-ros-base instead. It omits the GUI tools.
Step 4: Source the environment
echo "source /opt/ros/lyrical/setup.bash" >> ~/.bashrc
source ~/.bashrc
printenv | grep ROS
# ROS_DISTRO=lyrical
# ROS_VERSION=2
ROS_DOMAIN_ID isolates DDS traffic. Machines on the same network with the same ID see each other’s topics. Set a unique value (0–101) per robot or per student:
export ROS_DOMAIN_ID=42
Step 5: Verify with the talker and listener
Open two terminals.
# Terminal 1
ros2 run demo_nodes_cpp talker
# Terminal 2
ros2 run demo_nodes_py listener
The listener prints I heard: [Hello World: N]. A C++ publisher talking to a Python subscriber proves the middleware, type support, and language bindings all work.
Core Architecture: Nodes, Topics, Messages
Nodes
A node is a process (or a component inside one) with one job: read a LiDAR, run a controller, fuse an IMU with odometry. Keep nodes small. A monolithic node couples failure modes and prevents reuse.
ros2 node list # all running nodes
ros2 node info /talker # publishers, subscribers, services, actions
Topics
A topic is a named, typed channel. Publishers and subscribers never reference each other. Each only knows the topic name and message type.
| Property | Behavior |
|---|---|
| Pattern | Many publishers → many subscribers |
| Coupling | Decoupled (anonymous) |
| Typing | One message type per topic, enforced at connection time |
| Delivery | Governed by QoS profile |
| Best for | Streaming data: sensors, state, commands |
For request/response, use services. For long-running goals with feedback, use actions.
Messages
A message is a typed struct defined in a .msg file. Inspect any message with:
ros2 interface show geometry_msgs/msg/Twist
Vector3 linear # m/s along x, y, z (float64 each)
Vector3 angular # rad/s about x, y, z (float64 each)
Standard messages live in std_msgs, geometry_msgs, and sensor_msgs. Prefer sensor_msgs and geometry_msgs over std_msgs for robot data. They carry a std_msgs/Header with a timestamp and frame_id, which TF2 and sensor fusion need.
Bandwidth estimate. Serialized size is approximately the sum of field sizes plus a 4-byte CDR header:
Twist = 6 × float64 = 48 bytes → ~52 bytes on the wire
Rate = 50 Hz
BW = 52 B × 50 Hz = 2,600 B/s ≈ 2.6 kB/s
A 640×480 rgb8 Image is 921,600 bytes. At 30 Hz that is about 27.6 MB/s, which is why camera topics need compressed transport or intra-process communication.
Building Your First Publisher and Subscriber
You will build a temperature sensor node and a monitor node that filters the readings and flags overheating.
Create the workspace and package
mkdir -p ~/ros2_ws/src && cd ~/ros2_ws/src
ros2 pkg create --build-type ament_python first_steps \
--dependencies rclpy sensor_msgs std_msgs
Publisher node
Create first_steps/first_steps/temp_publisher.py:
import random
import rclpy
from rclpy.node import Node
from rclpy.qos import qos_profile_sensor_data # BEST_EFFORT, depth 5: standard for sensors
from sensor_msgs.msg import Temperature
class TempPublisher(Node):
def __init__(self):
super().__init__('temp_publisher')
# Topic name, message type, and QoS profile
self.pub = self.create_publisher(Temperature, 'engine/temperature',
qos_profile_sensor_data)
# 10 Hz timer -> period T = 1/f = 0.1 s
self.timer = self.create_timer(0.1, self.publish_reading)
self.base = 60.0 # simulated baseline, deg C
def publish_reading(self):
msg = Temperature()
msg.header.stamp = self.get_clock().now().to_msg() # builtin_interfaces/Time
msg.header.frame_id = 'engine_link' # TF frame of the sensor
msg.temperature = self.base + random.gauss(0.0, 1.5) # field unit: deg C
msg.variance = 2.25 # sigma^2 = 1.5^2
self.pub.publish(msg)
def main():
rclpy.init()
node = TempPublisher()
try:
rclpy.spin(node) # blocks, runs callbacks
except KeyboardInterrupt:
pass
finally:
node.destroy_node()
rclpy.try_shutdown()
if __name__ == '__main__':
main()
Subscriber node with an exponential filter
Raw readings carry noise. The subscriber applies a first-order low-pass filter:
x̂[k] = α · z[k] + (1 − α) · x̂[k−1] 0 < α ≤ 1
Here z[k] is the raw measurement and x̂[k] is the filtered estimate. A smaller α gives smoother output but more lag. The time constant is approximately τ ≈ T(1−α)/α. With T = 0.1 s and α = 0.2, τ = 0.4 s.
Create first_steps/first_steps/temp_monitor.py:
import rclpy
from rclpy.node import Node
from rclpy.qos import qos_profile_sensor_data
from sensor_msgs.msg import Temperature
class TempMonitor(Node):
def __init__(self):
super().__init__('temp_monitor')
# Declare parameters so they can be changed at runtime via `ros2 param set`
self.declare_parameter('alpha', 0.2) # filter coefficient
self.declare_parameter('limit_c', 65.0) # alert threshold, deg C
self.est = None # filtered estimate x_hat
self.sub = self.create_subscription(
Temperature, 'engine/temperature', self.on_temp,
qos_profile_sensor_data) # must be compatible with publisher QoS
def on_temp(self, msg: Temperature):
alpha = self.get_parameter('alpha').value
limit = self.get_parameter('limit_c').value
z = msg.temperature
# Initialize on first sample, then apply the recursive filter
self.est = z if self.est is None else alpha * z + (1.0 - alpha) * self.est
if self.est > limit:
self.get_logger().warn(f'Overtemp: {self.est:.1f} C > {limit:.1f} C')
else:
self.get_logger().info(f'raw={z:.1f} filtered={self.est:.1f}')
def main():
rclpy.init()
node = TempMonitor()
try:
rclpy.spin(node)
except KeyboardInterrupt:
pass
finally:
node.destroy_node()
rclpy.try_shutdown()
if __name__ == '__main__':
main()
Register entry points
Edit setup.py:
entry_points={
'console_scripts': [
'temp_publisher = first_steps.temp_publisher:main',
'temp_monitor = first_steps.temp_monitor:main',
],
},
Build, source, and run
cd ~/ros2_ws
rosdep install --from-paths src --ignore-src -y # resolve declared dependencies
colcon build --symlink-install --packages-select first_steps
source install/setup.bash
# Terminal 1
ros2 run first_steps temp_publisher
# Terminal 2
ros2 run first_steps temp_monitor
--symlink-install links Python files into install/, so edits take effect without rebuilding.
Inspecting the Graph with CLI Tools
The ros2 CLI is your primary debugging instrument. Learn these before reaching for a GUI.
| Command | Purpose |
|---|---|
ros2 topic list -t |
List topics with their types |
ros2 topic echo /engine/temperature |
Print messages live |
ros2 topic hz /engine/temperature |
Measure publish rate (expect ~10 Hz) |
ros2 topic bw /engine/temperature |
Measure bandwidth |
ros2 topic info /engine/temperature -v |
Show endpoints and their QoS |
ros2 param set /temp_monitor alpha 0.5 |
Change a parameter live |
ros2 run rqt_graph rqt_graph |
Visualize nodes and topics |
ros2 bag record -a |
Record all topics for replay |
Run ros2 topic info -v first when data fails to arrive. It prints each endpoint’s reliability, durability, and history settings side by side.
Custom Messages
Use a custom message when no standard type fits. Interfaces require a CMake package, even if your nodes are Python.
cd ~/ros2_ws/src
ros2 pkg create --build-type ament_cmake first_interfaces
mkdir first_interfaces/msg
first_interfaces/msg/ThermalAlert.msg:
std_msgs/Header header
string source # sensor or subsystem name
float32 temperature_c # filtered value, deg C
float32 limit_c # threshold that was exceeded
uint8 severity # 0=info, 1=warn, 2=critical
Add to CMakeLists.txt before ament_package():
find_package(rosidl_default_generators REQUIRED)
find_package(std_msgs REQUIRED)
rosidl_generate_interfaces(${PROJECT_NAME}
"msg/ThermalAlert.msg"
DEPENDENCIES std_msgs
)
Add to package.xml:
<buildtool_depend>rosidl_default_generators</buildtool_depend>
<depend>std_msgs</depend>
<exec_depend>rosidl_default_runtime</exec_depend>
<member_of_group>rosidl_interface_packages</member_of_group>
Build, source, and confirm:
colcon build --packages-select first_interfaces && source install/setup.bash
ros2 interface show first_interfaces/msg/ThermalAlert
QoS: Why Your Subscriber Hears Nothing
Quality of Service profiles control delivery guarantees. A publisher and subscriber connect only if their policies are compatible.
| Policy | Options | Effect |
|---|---|---|
| Reliability | RELIABLE / BEST_EFFORT |
Retransmit lost samples vs. drop them |
| Durability | VOLATILE / TRANSIENT_LOCAL |
Late joiners miss history vs. receive last samples |
| History | KEEP_LAST(n) / KEEP_ALL |
Queue depth |
| Deadline | duration | Alert if no message in period |
Compatibility is request-vs-offer: the subscriber’s requirement must not exceed what the publisher offers.
| Publisher | Subscriber | Connects? |
|---|---|---|
RELIABLE |
RELIABLE |
Yes |
RELIABLE |
BEST_EFFORT |
Yes |
BEST_EFFORT |
BEST_EFFORT |
Yes |
BEST_EFFORT |
RELIABLE |
No (silent mismatch) |
Use BEST_EFFORT with qos_profile_sensor_data for high-rate sensor streams, where a stale sample is worthless. Use RELIABLE for commands and configuration. Use TRANSIENT_LOCAL for latched data such as maps.
Real-World Workflow: Driving a Robot in a Circle with cmd_vel
Before wiring real hardware, validate your command pipeline in turtlesim. It uses the same geometry_msgs/msg/Twist interface as a differential-drive mobile base.
sudo apt install ros-lyrical-turtlesim -y # skip if you installed desktop
ros2 run turtlesim turtlesim_node
In a second terminal, publish a constant velocity at 10 Hz:
ros2 topic pub -r 10 /turtle1/cmd_vel geometry_msgs/msg/Twist \
"{linear: {x: 0.5}, angular: {z: 0.5}}"
For a differential-drive robot, the turning radius follows directly from the commanded velocities:
r = v / ω = 0.5 / 0.5 = 1.0 (distance units)
T_lap = 2π / ω = 2π / 0.5 ≈ 12.57 s
Check the result with the CLI:
ros2 topic hz /turtle1/pose # turtlesim reports pose at ~62.5 Hz
ros2 topic echo /turtle1/pose --once
Compare the measured lap time with 12.57 s. A mismatch on real hardware points to wheel slip, an incorrect wheel-separation parameter, or command latency from a congested network. The same workflow transfers to a physical base: publish Twist to /cmd_vel, read odometry, and compare commanded and measured trajectories.
Troubleshooting Checklist
| Symptom | Likely Cause | Fix |
|---|---|---|
ros2: command not found |
Environment not sourced | source /opt/ros/lyrical/setup.bash |
| Topic listed, no data | QoS mismatch | ros2 topic info -v, align policies |
| Nodes invisible across machines | Different ROS_DOMAIN_ID or multicast blocked |
Match IDs, check firewall and switch IGMP |
Package not found after build |
Workspace not sourced | source install/setup.bash |
| Python edits ignored | Built without --symlink-install |
Rebuild with the flag |
Apt cannot find ros-lyrical-* |
Wrong Ubuntu release | Confirm 26.04, or use Jazzy on 24.04 |
FAQ
Which ROS 2 version should I install in 2026?
Install Lyrical Luth on Ubuntu 26.04 for new projects. It is an LTS release with support through May 2031. Use Jazzy Jalisco on Ubuntu 24.04. Check that your key dependencies (Nav2, SLAM packages) have been released for your chosen distribution before you commit.
What is the difference between a ROS 2 node and a topic?
A node is a running process that performs computation. A topic is a named channel that nodes use to exchange typed messages. Nodes publish to or subscribe to topics without knowing about each other.
Why does my ROS 2 subscriber receive no messages?
The most common cause is a QoS mismatch. A BEST_EFFORT publisher cannot serve a RELIABLE subscriber. Run ros2 topic info <topic> -v to compare both endpoints’ QoS profiles. Also confirm that both nodes share the same ROS_DOMAIN_ID and message type.
Can I use Python for custom ROS 2 messages?
Yes, to use them, but not to define them. Define .msg files in an ament_cmake interface package, build it, then import the generated Python classes in any rclpy node.




