Intro to ROS Part 3: Nodes, Topics, Publishers, and Subscribers (Python)
2025-10-02 | By ShawnHymel
In the previous episodes of our ROS 2 tutorial series, we covered the theory behind nodes, topics, and services and demonstrated how to inspect and debug ROS applications using the command line. Now it’s time to write your first ROS 2 nodes in Python. This tutorial will walk you through creating a publisher and subscriber from scratch using ROS 2’s Python client library, rclpy.
By the end of this guide, you’ll know how to:
- Create a ROS 2 Python package
- Write a publisher that sends messages on a topic
- Write a subscriber that listens to those messages
- Build and run your package with colcon
- Visualize node connections using rqt_graph
The Docker image and code for this series can be found here: https://github.com/ShawnHymel/introduction-to-ros
Understanding ROS 2 Nodes and Topics
In ROS 2, applications are composed of nodes: independent processes that handle specific tasks like reading sensor data, processing algorithms, or controlling hardware. Each node is typically created as a class that subclasses the Node base class from the rclpy library. This structure allows you to encapsulate functionality cleanly and leverage object-oriented programming principles.
Nodes can communicate with each other using topics, which implement a publish/subscribe messaging model. A publisher sends data to a named topic, while one or more subscribers listen to that topic and receive the data. This decoupling of senders and receivers makes it easy to scale and maintain your robot software.
For example, imagine a temperature sensor node that publishes readings to a /temperature topic. Any node that needs temperature data, such as a logging or display node, can simply subscribe to that topic. This model promotes loose coupling, meaning publishers don’t need to know which nodes are subscribed, and subscribers don’t need to know the details of how messages are produced.
When you create a publisher or subscriber, you specify the topic name, the message type (such as std_msgs/msg/String), and an optional queue size or Quality of Service (QoS) profile to control message delivery behavior.
Setting Up the ROS 2 Environment
We’ll continue using the Docker-based ROS 2 environment configured in earlier episodes. If you’re on Windows, run the following command in PowerShell to launch the container:
docker run --rm -it -e PUID=$(wsl id -u) -e PGID=$(wsl id -g) -p 22002:22 -p 3000:3000 -v "${PWD}\workspace:/config/workspace" env-ros2
Navigate to localhost:3000 in your browser to open the VS Code environment preconfigured for ROS 2 development. You’ll be working inside the /config/workspace directory, which is mapped to your host and stores your custom packages.
If ros2 and colcon commands don’t work, ensure your ROS 2 environment is sourced:
source /opt/ros/jazzy/setup.bash
Creating a Python Package
A ROS 2 workspace is simply a directory where you store and build your ROS packages. It typically contains a src/ folder where your actual packages live. When you run colcon build, it creates a build/ directory with intermediate files, an install/ directory containing the built packages, and a log/ directory for diagnostics. This structure allows ROS 2 to manage multiple packages simultaneously and makes it easy to isolate your development environment. The modular design also encourages clean organization and reuse across projects. Keeping your workspace organized becomes more important as your robot software grows in complexity.
Create your ROS 2 workspace structure:
cd /config/workspace mkdir src cd src ros2 pkg create --build-type ament_python my_py_pkg
This generates a basic Python package with the following key elements:
- my_py_pkg/: your Python module
- resource/: contains an empty file named after the package (used for discovery)
- package.xml: ROS 2 package manifest (lists dependencies)
- setup.cfg and setup.py: Python build metadata
Writing the Publisher Node
Create a file named minimal_publisher.py inside my_py_pkg/my_py_pkg/:
import rclpy from rclpy.executors import ExternalShutdownException from rclpy.node import Node from example_interfaces.msg import String class MinimalPublisher(Node): """Publisher example that periodically sends out a string""" def __init__(self): """Constructor""" # Call the Node class constructor with the node name super().__init__('minimal_publisher') # Create a publisher object self._publisher = self.create_publisher(String, 'my_topic', 10) # Periodically call method self._timer = self.create_timer(0.5, self._timer_callback) # Counter for messages sent self._counter = 0 def _timer_callback(self): """Publishes a simple message to topic""" # Fill out String message msg = String() msg.data = f"Hello world: {self._counter}" # Publish message to topic self._publisher.publish(msg) self.get_logger().info(f"Publishing: {msg.data}") # Increment counter self._counter += 1 def main(args=None): """Main entrypoint""" # Initialize and run node try: rclpy.init() node = MinimalPublisher() rclpy.spin(node) # Catch ctrl+c or shutdown request except (KeyboardInterrupt, ExternalShutdownException): pass # Destroy node (now) and gracefully exit finally: if node is not None: node.destroy_node() if rclpy.ok(): rclpy.shutdown() if __name__ == '__main__': main()
In the call to create_publisher() and create_subscription(), you’ll notice the number 10 passed in as the third argument. This value defines the queue depth, which is a simple form of quality of service (QoS). In more advanced use cases, ROS 2 supports a wide range of QoS policies that control how messages are delivered under various conditions, such as lossy networks or when nodes are late to start. For now, the default settings are sufficient, but understanding QoS will be essential when working with real robots, especially those using wireless communication or with timing-sensitive data like video or sensor streams. You can read more about QoS settings here: https://docs.ros.org/en/jazzy/Concepts/Intermediate/About-Quality-of-Service-Settings.html
Update setup.py
Now register this script as an executable by editing setup.py. In the entry_points variable, add the following:
entry_points={ 'console_scripts': [ 'minimal_publisher = my_py_pkg.minimal_publisher:main'
],
}
Update package.xml
In your package, package.xml contains information used by the ROS system: metadata about the package and dependencies on other ROS packages. Every time you include another ROS package in your code, you should update the package.xml in your package.
This is often considered a “best practice” as your code may still compile and run without it! But you might run into bugs later on, as ROS uses this dependency information when running packages.
Add the following dependencies after the <buildtool_depend>
<depend>rclpy</depend> <depend>example_interfaces</depend>
Building and Running the Publisher
Return to the workspace root and build your package:
colcon build --packages-select my_py_pkg
Source the new environment so ROS can find the package:
source install/setup.bash
Now run the publisher node:
ros2 run my_py_pkg minimal_publisher
Open another terminal and inspect the topic:
ros2 topic list ros2 topic echo /my_topic
You should see the "Hello world" messages being published in real time.
Writing the Subscriber Node
Now let’s create a subscriber node to receive and print these messages. Create a file named minimal_subscriber.py in the same directory:
import rclpy from rclpy.executors import ExternalShutdownException from rclpy.node import Node from example_interfaces.msg import String class MinimalSubscriber(Node): """Subscriber example that prints messages to the console""" def __init__(self): """Constructor""" # Call the Node class constructor with the node name super().__init__('minimal_subscriber') # Create a subscription object self._subscription = self.create_subscription( String, 'my_topic', self._listener_callback, 10 ) def _listener_callback(self, msg): """Prints message to the console""" self.get_logger().info(f"Received message: {msg.data}") def main(args=None): """Main entrypoint""" # Initialize and run node try: rclpy.init() node = MinimalSubscriber() rclpy.spin(node) # Catch ctrl+c or shutdown request except (KeyboardInterrupt, ExternalShutdownException): pass # Destroy node (now) and gracefully exit finally: if node is not None: node.destroy_node() if rclpy.ok(): rclpy.shutdown() if __name__ == '__main__': main()
Add it to the setup.py entry points:
'minimal_subscriber = my_py_pkg.minimal_subscriber:main',
Rebuild your package:
colcon build --packages-select my_py_pkg source install/setup.bash
In one terminal, run:
ros2 run my_py_pkg minimal_publisher
In another terminal:
ros2 run my_py_pkg minimal_subscriber
You should see messages appear on both terminals, showing the subscriber successfully receiving data from the publisher.
Visualizing with rqt_graph
Launch the ROS graph visualization tool:
rqt_graph
This shows a graphical view of nodes and topics. You’ll see minimal_publisher and minimal_subscriber connected by the my_topic line.
Shutting Down and Next Steps
Use Ctrl+C to stop all nodes. To stop the Docker container entirely, use Docker Desktop or the CLI (from another terminal):
docker ps docker stop <CONTAINER_ID>
You’ve now built and connected your first ROS 2 nodes using Python. This foundation will support more advanced interactions in future lessons, including parameters, services, and hardware integration. Up next, we’ll implement a service-server interaction using the same framework.
Stay tuned!