Blog

Your First ROS 2 Node in Python: From Hello World to Driving a Real Robot

Tom Cosgrove,

Reading Room: The Fictionlab Blog

TL;DR: This ROS 2 Python node tutorial walks you from an empty workspace to a node that moves a physical mobile robot. You will create a colcon workspace, generate an ament_python package, write a publisher and a subscriber with rclpy, register them as console entry points in setup.py, build with colcon build, and run them with ros2 run. The final step replaces the string topic with /cmd_vel and sends geometry_msgs/msg/Twist commands to a differential-drive or skid-steer platform such as the Leo Rover. All commands follow the official ROS 2 documentation and target a ROS 2 Humble or Jazzy installation on Ubuntu.

Why Does Your First ROS 2 Python Node Matter More Than It Looks?

A ROS 2 node is not a program in the usual sense. It is a participant in a distributed graph. Calling rclpy.init() initializes the ROS 2 context; when your Python process creates a node and its publishers or subscriptions, they join a DDS domain and are discovered by compatible endpoints on the same domain ID. Networking, serialization, and discovery are handled by the middleware.

That is the reason this ROS 2 Python node tutorial is worth doing on real hardware rather than only in simulation. The same twenty lines of rclpy that print “Hello World” to a terminal can command wheels once you use the appropriate message type, topic, and robot driver. The gap between a toy example and a field robot is smaller than most beginners expect, and understanding exactly where that gap sits saves hours of debugging later.

The failure modes that bite newcomers are rarely algorithmic. They are environment problems: an unsourced overlay, a mismatched ROS_DOMAIN_ID, a missing entry point in setup.py, or a QoS profile that silently refuses to connect a publisher to a subscriber. Each section below flags the specific symptom so you can recognize it when it happens.

What you need before starting

The steps below assume a working ROS 2 installation. Confirm the following before writing any code:

  • Ubuntu with ROS 2 Humble Hawksbill or ROS 2 Jazzy Jalisco installed from the official apt repository.
  • Python 3 and the python3-colcon-common-extensions package.
  • The ability to run ros2 topic list after sourcing /opt/ros/$ROS_DISTRO/setup.bash without errors.
  • For the hardware section: a differential-drive or skid-steer robot running a ROS 2 driver that subscribes to /cmd_vel, on the same network.

If ros2 is not found, the installation is either incomplete or the setup file has not been sourced in that terminal. Sourcing is per-terminal and not persistent unless you add it to ~/.bashrc.

How Do You Create a ROS 2 Workspace and Python Package?

Every ROS 2 Python node tutorial starts in a workspace. A workspace is a directory with a src subfolder where packages live, plus three build artifact directories that colcon generates for you.

Create the workspace and enter the source directory:

mkdir -p ~/ros2_ws/src
cd ~/ros2_ws/src

Now generate a package. The ros2 pkg create command scaffolds the directory structure, the manifest, and the Python setup files in one shot:

ros2 pkg create --build-type ament_python --license Apache-2.0 my_first_nodes

The --build-type ament_python flag matters. It tells colcon to treat the package as a setuptools project rather than a CMake project. The command produces this layout:

  • my_first_nodes/my_first_nodes/ – the Python module where your node source files go.
  • my_first_nodes/my_first_nodes/__init__.py – marks the directory as a Python package.
  • my_first_nodes/resource/my_first_nodes – the package marker used by the ament resource index.
  • my_first_nodes/package.xml – the manifest with dependencies and metadata.
  • my_first_nodes/setup.py – the setuptools script, including the entry points that make your nodes executable.
  • my_first_nodes/setup.cfg – tells ROS 2 where to install scripts so ros2 run can find them.
  • my_first_nodes/test/ – linter and style test stubs.

Open package.xml and add the runtime dependencies below the existing tags. These are the packages your nodes import at runtime:

<depend>rclpy</depend>
<depend>std_msgs</depend>
<depend>geometry_msgs</depend>

rclpy is the ROS Client Library for Python. std_msgs gives you the String type for the first example, and geometry_msgs provides Twist, which you need for velocity commands.

What Does a Minimal rclpy Publisher Look Like?

Create the file ~/ros2_ws/src/my_first_nodes/my_first_nodes/talker.py. This node publishes a string once per second and logs what it sent.

import rclpy
from rclpy.node import Node
from std_msgs.msg import String

class Talker(Node):
    def __init__(self):
        # 'talker' is the node name shown by 'ros2 node list'
        super().__init__('talker')
        # Topic 'chatter', queue depth 10 messages
        self.publisher_ = self.create_publisher(String, 'chatter', 10)
        # Timer fires the callback every 1.0 s
        self.timer = self.create_timer(1.0, self.timer_callback)
        self.count = 0

    def timer_callback(self):
        msg = String()
        msg.data = f'Hello World: {self.count}'
        self.publisher_.publish(msg)
        self.get_logger().info(f'Published: "{msg.data}"')
        self.count += 1

def main(args=None):
    rclpy.init(args=args)
    node = Talker()
    try:
        rclpy.spin(node)  # blocks, processes timers and callbacks
    except KeyboardInterrupt:
        pass
    finally:
        node.destroy_node()
        rclpy.try_shutdown()

if __name__ == '__main__':
    main()

Three details deserve attention in this ROS 2 Python node tutorial. First, rclpy.spin() blocks the thread and runs the executor; without it, timers never fire. Second, the queue depth of 10 is part of the QoS profile, not an arbitrary buffer size. Third, destroy_node() releases ROS resources explicitly instead of waiting for garbage collection.

And the matching subscriber

Create ~/ros2_ws/src/my_first_nodes/my_first_nodes/listener.py:

import rclpy
from rclpy.node import Node
from std_msgs.msg import String

class Listener(Node):
    def __init__(self):
        super().__init__('listener')
        # Type and topic name must match; QoS settings must be compatible
        self.subscription = self.create_subscription(
            String, 'chatter', self.listener_callback, 10)

    def listener_callback(self, msg):
        self.get_logger().info(f'Received: "{msg.data}"')

def main(args=None):
    rclpy.init(args=args)
    node = Listener()
    try:
        rclpy.spin(node)
    except KeyboardInterrupt:
        pass
    finally:
        node.destroy_node()
        rclpy.try_shutdown()

if __name__ == '__main__':
    main()

How Do You Build and Run the Nodes?

Python files in the package directory are not executables yet. ROS 2 finds them through console script entry points declared in setup.py. Open that file and edit the entry_points dictionary:

entry_points={
    'console_scripts': [
        'talker = my_first_nodes.talker:main',
        'listener = my_first_nodes.listener:main',
    ],
},

The syntax is executable_name = package.module:function. Forgetting this step is a common failure in any ROS 2 Python node tutorial, and one likely symptom is No executable found.

Build from the workspace root, not from inside the package:

cd ~/ros2_ws
colcon build --packages-select my_first_nodes

Open a new terminal, source the underlay and then the overlay, and run the talker:

source /opt/ros/$ROS_DISTRO/setup.bash
source ~/ros2_ws/install/setup.bash
ros2 run my_first_nodes talker

In a third terminal, source both setup files again and run ros2 run my_first_nodes listener. The listener should start printing received messages within a second. Useful introspection commands while both nodes run include ros2 node list, ros2 topic list, ros2 topic echo /chatter, and ros2 topic hz /chatter, which should report a rate close to 1 Hz.

If the listener stays silent, check three things in order: both terminals sourced the overlay, both shells share the same ROS_DOMAIN_ID value, and the topic name has no typo. ros2 topic info /chatter --verbose shows publisher and subscriber counts along with their QoS settings.

How Do You Turn That Node Into Robot Motion?

The step from a string publisher to a motion command is a change of message type and topic name. Differential-drive and skid-steer platforms in ROS 2, including the Leo Rover, commonly subscribe to geometry_msgs/msg/Twist on /cmd_vel. The message carries two vectors: linear in meters per second and angular in radians per second. A skid-steer or differential robot uses linear.x for forward speed and angular.z for yaw rate; the other four components are typically ignored by the driver.

Before writing code, verify the interface on your robot with ros2 topic info /cmd_vel and ros2 interface show geometry_msgs/msg/Twist. Some platforms remap /cmd_vel into a namespace or route it through a twist multiplexer, in which case you publish to the mux input topic instead. Consult your robot driver’s documentation for how velocity commands map to wheel speeds and which command timeout it uses.

Create ~/ros2_ws/src/my_first_nodes/my_first_nodes/square_driver.py. This node drives a rough square: forward, turn, repeat. It is a state machine driven by a timer, with no odometry feedback, so treat the geometry as approximate.

import rclpy
from rclpy.node import Node
from rclpy.signals import SignalHandlerOptions
from geometry_msgs.msg import Twist

class SquareDriver(Node):
    def __init__(self):
        super().__init__('square_driver')
        self.pub = self.create_publisher(Twist, '/cmd_vel', 10)

        # Declared parameters, overridable with --ros-args -p
        self.declare_parameter('linear_speed', 0.15)  # m/s
        self.declare_parameter('angular_speed', 0.5)  # rad/s
        self.declare_parameter('leg_seconds', 4.0)
        self.declare_parameter('turn_seconds', 3.0)

        self.v = self.get_parameter('linear_speed').value
        self.w = self.get_parameter('angular_speed').value
        self.leg = self.get_parameter('leg_seconds').value
        self.turn = self.get_parameter('turn_seconds').value

        self.phase = 'drive'
        self.elapsed = 0.0
        self.period = 0.1  # publish at 10 Hz
        self.timer = self.create_timer(self.period, self.tick)

    def tick(self):
        msg = Twist()
        if self.phase == 'drive':
            msg.linear.x = self.v
            if self.elapsed >= self.leg:
                self.phase, self.elapsed = 'turn', 0.0
        else:
            msg.angular.z = self.w
            if self.elapsed >= self.turn:
                self.phase, self.elapsed = 'drive', 0.0
        self.pub.publish(msg)
        self.elapsed += self.period

    def stop(self):
        # Send an explicit zero Twist during normal Python shutdown
        self.pub.publish(Twist())

def main(args=None):
    rclpy.init(args=args, signal_handler_options=SignalHandlerOptions.NO)
    node = SquareDriver()
    try:
        rclpy.spin(node)
    except KeyboardInterrupt:
        pass
    finally:
        node.stop()
        node.destroy_node()
        rclpy.try_shutdown()

if __name__ == '__main__':
    main()

Add 'square_driver = my_first_nodes.square_driver:main', to the entry points, rebuild, and source the overlay again.

Running it safely on hardware

Follow this order the first time you publish velocity commands to a real platform. Each step exists because skipping it has a specific consequence.

  1. Put the robot on a stand with the wheels off the ground, or clear at least two meters of open floor in every direction.
  2. Confirm the computer and the robot share a network and the same ROS_DOMAIN_ID; run ros2 node list and check the robot’s nodes appear.
  3. Test the interface manually first: ros2 topic pub --rate 10 /cmd_vel geometry_msgs/msg/Twist "{linear: {x: 0.1}, angular: {z: 0.0}}" and stop with Ctrl+C.
  4. Launch your node with conservative parameters: ros2 run my_first_nodes square_driver --ros-args -p linear_speed:=0.1 -p angular_speed:=0.3
  5. Keep the manual stop method within reach, whether that is the hardware switch or the terminal running the node.

Two behaviors in that code reflect how real drivers work. Publishing at 10 Hz rather than once is deliberate: most ROS 2 base controllers implement a command timeout, typically a few hundred milliseconds, and stop the motors if no new Twist arrives. Publishing a zero Twist during normal shutdown is also deliberate, because a controller may continue using the last command until the timeout expires or a new command replaces it.

The turn durations here produce an approximate square at best. Wheel slip, surface friction, and battery voltage all shift the real yaw rate away from the commanded value, and skid-steer platforms turn by slipping on purpose. Closing that loop requires odometry feedback from /odom or a navigation stack, which is the natural next topic after this ROS 2 Python node tutorial.

What Should You Learn Next?

You now have the pattern that underlies nearly every ROS 2 Python node: inherit from Node, create publishers and subscriptions in the constructor, do work in callbacks, and let the executor spin. Extending it in useful directions follows a few recognizable paths:

  • Subscribe to sensor data. Read sensor_msgs/msg/Imu or sensor_msgs/msg/LaserScan and gate your Twist output on what the sensor reports. Note that sensor topics often use a best-effort QoS profile; a reliable subscription will not connect to a best-effort publisher.
  • Close the loop with odometry. Subscribe to nav_msgs/msg/Odometry and drive until a pose target is reached rather than until a timer expires.
  • Write launch files. A Python launch file starts several nodes with parameters in one command instead of three terminals.
  • Record and replay. ros2 bag record /cmd_vel /odom captures a run you can replay offline for analysis.
  • Move to Nav2. Once your node reliably reacts to sensor input, the navigation stack handles planning and obstacle avoidance while your code supplies goals.

This workflow is identical whether you run it on a laptop in simulation or on a rover in a field. Platforms designed for teaching, like the Leo Rover with its ROS 2 driver and documented /cmd_vel interface, exist so the step from a terminal to wheels on soil does not require rewriting anything you just learned.

Frequently Asked Questions

Why does ros2 run report “No executable found”?

In almost every case the entry point is missing from setup.py, or the workspace was built before the entry point was added. Add the console_scripts line, run colcon build again, then source install/setup.bash in a fresh terminal. Building from the workspace root ensures colcon can discover the package.

Do I need to rebuild after every change to a Python file?

By default yes, because colcon build copies files into the install space. Building with colcon build --symlink-install creates symlinks instead, so edits to existing Python files take effect on the next ros2 run without rebuilding. You still need to rebuild after changing an entry point, setup.py, or package.xml.

My publisher and subscriber both run, but no messages arrive. What now?

Check in this order: identical topic names, identical message types, compatible QoS profiles, and the same ROS_DOMAIN_ID in both shells. Run ros2 topic info /your_topic --verbose to see endpoint counts and QoS settings. Reliability and durability incompatibilities between endpoints block the connection silently, with no error printed.

What is the difference between rclpy.shutdown() and rclpy.try_shutdown()?

rclpy.shutdown() can raise an exception if the context has already been shut down, which can happen when a signal handler got there first. rclpy.try_shutdown() is a no-op in that situation, which makes it safer inside a finally block.

Can I run this tutorial without a physical robot?

Yes. The talker and listener need nothing but a ROS 2 installation. For the /cmd_vel part, use a simulated robot in Gazebo or run ros2 topic echo /cmd_vel in another terminal to confirm your Twist messages have the values you expect before touching hardware.

Why publish cmd_vel continuously instead of once per command?

Base controllers typically implement a watchdog timeout and stop the motors if no new Twist arrives within a short window, often a few hundred milliseconds depending on the driver. This is a safety feature: if your node crashes or the network drops, the robot halts instead of continuing at the last commanded speed. Publishing at 10 to 20 Hz keeps the watchdog satisfied.

Should I use Humble or Jazzy for learning?

Both work with the code above; the rclpy API shown here is stable across these distributions. Pick the distribution your robot’s driver officially supports, and check the platform documentation before installing. Using the same documented ROS 2 distribution on the robot and development machine avoids compatibility problems.

Fictionlab builds Leo Rover as an open-source platform for exactly this kind of hands-on learning, with full ROS 2 support and documentation that assumes you will modify everything. See how it is used in classrooms and research groups at Leo Rover for education, or browse configurations and accessories in the Fictionlab shop.


Read more