Beginner · Lesson 10 of 10

Hello, ROS 2: a publisher, a subscriber and tf2

Nodes, topics, messages and QoS in ROS 2 Jazzy, a talker and listener in one Python process, and a tf2 frame tree with static and dynamic transforms looked up from a Buffer

BeginnerROS 245 minUpdated 14 Nov 2026Free until 31 Jan 2027

You will learn

  • Explain what a ROS 2 node, topic, message and QoS profile are, and state the default QoS for publishers and subscriptions
  • Write a publisher and a subscriber in rclpy, run them in one process with a single-threaded executor, and shut down cleanly
  • Broadcast a static and a dynamic transform and look up a chained transform with a tf2 Buffer and TransformListener
  • Transform a point between frames with tf2 and check the result by hand
  • Run the ros2 command-line tools to list, echo and inspect topics and transforms in the Jazzy Docker image on macOS or Linux

Before this: Frames, rotations and transforms with numpy, Differential-drive kinematics and odometry

ROS 2 is open-source middleware for robot software. Drivers, estimators, planners and controllers run as separate programs and exchange data through it. This lesson is for readers who know Python and the frames from lesson 2 and want a first working contact with ROS 2. You will write a publisher and a subscriber, then build the odom to base_link to laser frame tree from lesson 7 with tf2 and use it to move a sensor reading between frames. Both programs run in a single process and exit on their own, so you can run them in a container without a desktop.

ROS 2 Jazzy Jalisco

All code here is tested on ROS 2 Jazzy Jalisco (LTS, May 2024), the tenth ROS 2 release. According to the ROS 2 documentation it was released on 23 May 2024 and is supported until May 2029. Its Tier 1 platforms are Ubuntu 24.04 (amd64 and arm64) and Windows 10. macOS is a Tier 3 platform for Jazzy, on amd64 only, so on a Mac, Apple Silicon or Intel, the practical route is a Linux container.

The official ros image on Docker Hub publishes a jazzy-ros-base tag for both amd64 and arm64. It contains the core libraries, rclpy, tf2 and the standard messages, but no graphical tools. On macOS, install a Docker runtime, save the two Python blocks below as files in a folder, and start a shell in the container with that folder mounted:

# On macOS (or Linux) with Docker installed. The first pull needs a network connection.
docker pull ros:jazzy-ros-base
mkdir -p ~/ros2-hello && cd ~/ros2-hello    # save talker_listener.py and tf2_demo.py here
docker run -it --rm --name ros2-hello -v "$PWD":/work -w /work ros:jazzy-ros-base bash

The image’s entrypoint sources /opt/ros/jazzy/setup.bash, so python3 talker_listener.py works at the prompt straight away. For a second shell in the same container, which you need for the command-line tools later on, run this on the Mac:

docker exec -it ros2-hello bash -c "source /opt/ros/jazzy/setup.bash && exec bash"

Nodes, topics, messages and QoS

A node is one participant in the ROS graph, usually one program with one job: a lidar driver, an odometry estimator, a path planner. A topic is a named channel such as /scan or /cmd_vel. Nodes publish messages to a topic and subscribe to it without knowing who is on the other end. A message is a typed data structure defined in an interface file. geometry_msgs/msg/Twist carries a velocity command and sensor_msgs/msg/LaserScan a lidar scan. Topics suit streams of data. ROS 2 also has services for request and reply, and actions for long-running goals with feedback.

Underneath, ROS 2 hands messages to a DDS implementation through a middleware layer. Jazzy’s default is eProsima Fast DDS. By default, nodes find each other automatically by advertising themselves on the local network, with no central server.

Each publisher and subscription has a QoS (quality of service) profile. The defaults are: keep the last 10 messages, reliable delivery, and volatile durability, meaning late joiners do not receive old messages. Sensor drivers often publish with best-effort reliability instead, because a fresh scan matters more than a guaranteed old one. A subscription only connects to a publisher if the publisher offers at least the quality the subscription requests. A reliable subscription receives nothing from a best-effort publisher. rclpy logs an “incompatible QoS” warning when it discovers such a pair, but nothing reaches your callback, and the warning is easy to miss in a busy log. A QoS mismatch is the first thing to check when a topic exists but your callback never fires.

ROS graphnodetalkertopic /chatterstd_msgs/msg/StringnodelistenerpublishesdeliversQoS: keep last 10, reliable, volatiletf2 frame treeodombase_linklaser/tf, dynamic/tf_static, fixedparent to child: each frame has exactly one parentframe_broadcaster publishes both, frame_listener fills a tf2 Buffer
Top: the ROS graph built by the first block. The talker node publishes std_msgs/msg/String messages on the /chatter topic and the listener node subscribes to it, both with the default QoS. Bottom: the tf2 frame tree built by the second block. The odom to base_link transform changes as the robot drives and goes out on /tf. The base_link to laser transform is fixed and goes out once on /tf_static.

A talker and a listener in one process

On a real robot the publisher and the subscriber would be separate programs, started by a launch file. For a first test, one process is simpler and needs no second terminal. Each class below is a node. The talker publishes a string 10 times a second from a timer callback. The listener stores every message it receives.

A node does nothing until something calls its callbacks. That is the job of an executor. rclpy.spin(node) would run forever, so instead we create a SingleThreadedExecutor, add both nodes, and call spin_once in a loop with a short timeout. Each call waits up to 0.05 s for one piece of work (a timer firing or a message arriving) and runs its callback. The loop stops after five messages or 200 calls, whichever comes first, then the nodes are destroyed and rclpy.shutdown() releases the middleware. Save it as talker_listener.py.

# ros2
import rclpy
from rclpy.executors import SingleThreadedExecutor
from rclpy.node import Node
from rclpy.qos import QoSProfile, ReliabilityPolicy, HistoryPolicy
from std_msgs.msg import String


class Talker(Node):
    def __init__(self):
        super().__init__("talker")
        qos = QoSProfile(history=HistoryPolicy.KEEP_LAST, depth=10, reliability=ReliabilityPolicy.RELIABLE)
        self.pub = self.create_publisher(String, "chatter", qos)
        self.count = 0
        self.timer = self.create_timer(0.1, self.tick)       # 10 Hz

    def tick(self):
        msg = String()
        msg.data = f"hello {self.count}"
        self.pub.publish(msg)
        self.count += 1


class Listener(Node):
    def __init__(self):
        super().__init__("listener")
        self.received = []
        self.sub = self.create_subscription(String, "chatter", self.on_msg, 10)

    def on_msg(self, msg: String):
        self.received.append(msg.data)


rclpy.init()
talker, listener = Talker(), Listener()
executor = SingleThreadedExecutor()
executor.add_node(talker)
executor.add_node(listener)

for _ in range(200):                      # bounded: at most about 200 x 0.05 s
    executor.spin_once(timeout_sec=0.05)
    if len(listener.received) >= 5:
        break

print(f"talker published {talker.count} messages")
print(f"listener received {len(listener.received)}: {listener.received}")
print("topics seen by the listener node:", sorted(n for n, _ in listener.get_topic_names_and_types()))

executor.shutdown()
talker.destroy_node()
listener.destroy_node()
rclpy.shutdown()
print("shut down cleanly")
talker published 5 messages
listener received 5: ['hello 0', 'hello 1', 'hello 2', 'hello 3', 'hello 4']
topics seen by the listener node: ['/chatter', '/parameter_events', '/rosout']
shut down cleanly

All five messages arrived, in order. Two topics appear that we did not create. /rosout carries log messages from every node and /parameter_events announces parameter changes. On a busy network, or between two separate processes, the first message or two can be lost before discovery completes, because the default durability is volatile. If your output starts at hello 1, that is why.

std_msgs/msg/String is the classic first message, but its own interface file now marks it as deprecated and recommends defining a message with a meaningful name. It is still fine for a test. Real interfaces use a specific type, such as sensor_msgs/msg/Temperature for a temperature reading, so that every subscriber knows the units and the frame.

The same graph from the command line

The ros2 command-line tool inspects a running system. In one shell inside the container, publish at 2 Hz from the command line. In a second shell (the docker exec command above), inspect it:

# shell 1: publish a String on /chatter twice a second until Ctrl-C
ros2 topic pub -r 2 /chatter std_msgs/msg/String "{data: hello from the CLI}"

# shell 2: look at the graph
ros2 topic list                      # /chatter, /parameter_events, /rosout
ros2 topic echo --once /chatter      # print one message and exit
ros2 topic info -v /chatter          # publishers, subscribers and their QoS
ros2 topic hz /chatter               # measured publishing rate
ros2 interface show std_msgs/msg/String
ros2 node list

ros2 topic info -v prints the QoS of every endpoint, so it is the tool for the silent mismatch described above. The ros2 CLI cheat sheet lists the other commands.

tf2: frames that move

A robot has many coordinate frames: the odometry frame, the body, each sensor, each joint of an arm. Every sensor reading is expressed in the frame of the sensor that took it. To use a lidar point for navigation you need it in odom or map, which means chaining several transforms, some fixed and some changing with time. tf2 is the ROS 2 library that does this bookkeeping.

The frames form a tree: every frame has exactly one parent. Each transform is published as a geometry_msgs/msg/TransformStamped, with a time stamp, a parent frame_id, a child_frame_id, a translation in metres and a rotation as a quaternion. ROS messages order quaternions as (x, y, z, w), with w last. Transforms that change go out on the /tf topic and are buffered with their time stamps. Transforms that never change, such as where a sensor is bolted on, go out once on /tf_static. The static broadcaster in Jazzy publishes with transient-local durability and a depth of 1, so a node that starts later still receives them.

To look a transform up, a node creates a Buffer, which stores recent transforms, and a TransformListener, which subscribes to both topics and fills the buffer. lookup_transform(target, source, time) then returns the transform that takes data from the source frame into the target frame, chaining as many tree edges as needed. Asking for Time(), which is time zero, means “the latest available”.

Static and dynamic transforms in one process

The second block reuses the REP 105 chain from lesson 7. A broadcaster node publishes base_link to laser once as a static transform: the laser sits 0.10 m ahead of the axle and 0.20 m up. It also publishes odom to base_link at 50 Hz as the robot drives at 0.5 m/s while turning at 0.2 rad/s. For repeatable output the odometry time comes from a tick counter, and the broadcaster stops after 50 ticks, 1 s of driving. A second node holds the buffer and listener. Then we look up odom to laser and move a lidar return 2 m in front of the sensor into the odom frame. Save it as tf2_demo.py.

# ros2
import math

import rclpy
from geometry_msgs.msg import PointStamped, TransformStamped
from rclpy.duration import Duration
from rclpy.executors import SingleThreadedExecutor
from rclpy.node import Node
from rclpy.time import Time
from tf2_ros import Buffer, StaticTransformBroadcaster, TransformBroadcaster, TransformListener
import tf2_geometry_msgs  # noqa: F401  registers PointStamped with Buffer.transform


def make_tf(stamp, parent: str, child: str, x: float, y: float, z: float, yaw: float) -> TransformStamped:
    t = TransformStamped()
    t.header.stamp = stamp
    t.header.frame_id = parent
    t.child_frame_id = child
    t.transform.translation.x, t.transform.translation.y, t.transform.translation.z = x, y, z
    # Rotation about z only. ROS quaternions are (x, y, z, w).
    t.transform.rotation.z = math.sin(yaw / 2.0)
    t.transform.rotation.w = math.cos(yaw / 2.0)
    return t


class Broadcaster(Node):
    """Publishes odom -> base_link as the robot drives, and base_link -> laser once (static)."""

    def __init__(self):
        super().__init__("frame_broadcaster")
        self.static_br = StaticTransformBroadcaster(self)
        self.dynamic_br = TransformBroadcaster(self)
        now = self.get_clock().now().to_msg()
        self.static_br.sendTransform(make_tf(now, "base_link", "laser", 0.10, 0.0, 0.20, 0.0))
        self.ticks = 0
        self.timer = self.create_timer(0.02, self.tick)    # 50 Hz

    def tick(self):
        # Pretend odometry: drive forward at 0.5 m/s while turning at 0.2 rad/s.
        # Time comes from the tick count so the printed numbers are repeatable.
        t = self.ticks * 0.02
        self.ticks += 1
        if self.ticks == 50:
            self.timer.cancel()                             # stop after 1 s of driving
        v, w = 0.5, 0.2
        yaw = w * t
        x = v / w * math.sin(yaw)
        y = v / w * (1.0 - math.cos(yaw))
        self.dynamic_br.sendTransform(make_tf(self.get_clock().now().to_msg(), "odom", "base_link", x, y, 0.0, yaw))


rclpy.init()
broadcaster = Broadcaster()
listener_node = Node("frame_listener")
tf_buffer = Buffer()
tf_listener = TransformListener(tf_buffer, listener_node, spin_thread=False)

executor = SingleThreadedExecutor()
executor.add_node(broadcaster)
executor.add_node(listener_node)

for _ in range(200):                                  # bounded: about 4 s at most
    executor.spin_once(timeout_sec=0.02)
    if broadcaster.ticks >= 50:
        break
for _ in range(10):                                   # let the listener drain the last messages
    executor.spin_once(timeout_sec=0.02)

tf = tf_buffer.lookup_transform("odom", "laser", Time(), timeout=Duration(seconds=0.0))
tr, q = tf.transform.translation, tf.transform.rotation
yaw = 2.0 * math.atan2(q.z, q.w)
print(f"odom -> laser: x={tr.x:.3f} y={tr.y:.3f} z={tr.z:.3f} m, yaw={yaw:.3f} rad")

# A laser return 2 m straight ahead of the sensor, expressed in the odom frame.
p = PointStamped()
p.header.frame_id = "laser"
p.header.stamp = tf.header.stamp
p.point.x = 2.0
p_odom = tf_buffer.transform(p, "odom")
print(f"point 2 m ahead of the laser, in odom: x={p_odom.point.x:.3f} y={p_odom.point.y:.3f} z={p_odom.point.z:.3f}")
print(tf_buffer.all_frames_as_string().strip())

executor.shutdown()
broadcaster.destroy_node()
listener_node.destroy_node()
rclpy.shutdown()
print("shut down cleanly")
odom -> laser: x=0.585 y=0.067 z=0.200 m, yaw=0.196 rad
point 2 m ahead of the laser, in odom: x=2.547 y=0.457 z=0.200
Frame laser exists with parent base_link.
Frame base_link exists with parent odom.
shut down cleanly

Check the result by hand, using the arc formulas from lesson 7. At the last tick, t = 0.98 s, the heading is 0.2 times 0.98, or 0.196 rad. The base is at x = 2.5 sin(0.196) = 0.487 m and y = 2.5 (1 - cos(0.196)) = 0.048 m, where 2.5 m is the turn radius v / omega. The laser is 0.10 m further along the heading: x = 0.487 + 0.10 cos(0.196) = 0.585 and y = 0.048 + 0.10 sin(0.196) = 0.067. The height comes only from the static transform, 0.200 m. The point 2 m ahead of the laser is 2.10 m ahead of the base along the same heading, which gives x = 2.547 and y = 0.457. The printed numbers match.

tf2_geometry_msgs is imported only for its side effect: it teaches Buffer.transform how to handle PointStamped and the other geometry message types. Without the import, Buffer.transform raises a TypeException saying the type is not loaded or supported, which does not name the missing module.

Transforms from the command line

The same tree can be built and inspected with the tools that ship in tf2_ros:

# shell 1: publish the static laser mount
ros2 run tf2_ros static_transform_publisher --x 0.1 --z 0.2 --frame-id base_link --child-frame-id laser

# shell 2: print the transform from laser data into base_link once a second
ros2 run tf2_ros tf2_echo base_link laser
ros2 topic echo --once /tf_static

static_transform_publisher takes the translation in metres and either roll, pitch and yaw in radians or a quaternion with --qx --qy --qz --qw. ros2 run tf2_tools view_frames, which is included in the ros-base image, listens for a few seconds and writes a PDF of the whole frame tree into the current folder, which is the fastest way to find a frame with no parent or two parents.

Where to go next

The ROS 2 talker, listener and tf2 project turns these blocks into a package with separate nodes and a launch file. The ROS and middleware hub and the ROS platform page cover packages, launch files, parameters and the navigation and manipulation stacks built on these pieces.

Exercise

  1. Change the listener’s subscription to best-effort reliability with a QoSProfile and keep the talker reliable. Do messages still arrive? Then swap them (best-effort talker, reliable listener) and run ros2 topic info -v against the command-line version to see why.
  2. In the tf2 block, change the laser mount to 0.10 m ahead and 0.05 m to the left of base_link, rotated by 0.5 rad about z. Predict the new odom to laser translation by hand, then run the block to check it.
  3. After the loop, call tf_buffer.lookup_transform("laser", "odom", Time()) and compare it with the odom to laser result. Explain how the two are related.

Summary

  • A ROS 2 system is a graph of nodes that exchange typed messages over named topics. Jazzy’s default middleware is Fast DDS, and nodes discover each other without a central server.
  • The default QoS is keep last 10, reliable, volatile. A subscription receives nothing from a publisher that offers lower quality than it requests, and the only sign is a logged warning.
  • An executor runs the callbacks. Spinning with spin_once in a bounded loop, then destroying nodes and calling rclpy.shutdown(), gives a program that exits on its own.
  • tf2 keeps a tree of frames. Changing transforms go on /tf with time stamps, fixed ones go once on /tf_static, and a Buffer with a TransformListener chains them for any lookup.
  • ROS messages store quaternions as (x, y, z, w). Check one transform by hand whenever you build a new frame tree.

Further reading