Skip to main content
🗺️ TF2 GuideUpdated June 2026

ROS 2 TF2 Guide 2026Broadcasters · Lookup · Debug · Frame Tree

Complete 2026 guide to TF2 — the ROS 2 transform library. Covers static and dynamic transform broadcasters, Python lookup_transform API with point transformation, CLI debug tools (view_frames, tf2_echo), robot_state_publisher integration, the standard REP-105 robot frame tree, and the five most common TF errors with fixes.

StaticBroadcasterlookup_transformview_framesrobot_state_publisherREP-105

Standard Robot TF Frame Tree (REP-105)

FrameParentPublished ByPurpose
mapSLAM toolbox / AMCLGlobal fixed frame
odommapSLAM / AMCLContinuous, no jumps
base_footprintodomDiffDriveControllerGround projection
base_linkbase_footprintrobot_state_publisherRobot body center
lidar_linkbase_linkStatic broadcasterLidar sensor origin
camera_linkbase_linkStatic broadcasterCamera optical frame

All frames must connect back to a common root. map → odom → base_link → sensors is the REP-105 standard.

Broadcast Transforms

Static Transform Broadcaster

Publish a fixed transform between two frames — e.g. sensor mounting position on the robot. Published once and latched.

python
#!/usr/bin/env python3
import rclpy
from rclpy.node import Node
from tf2_ros import StaticTransformBroadcaster
from geometry_msgs.msg import TransformStamped


class StaticFramePublisher(Node):
    def __init__(self):
        super().__init__("static_tf_publisher")
        self._broadcaster = StaticTransformBroadcaster(self)
        self._publish()

    def _publish(self):
        t = TransformStamped()
        t.header.stamp = self.get_clock().now().to_msg()
        t.header.frame_id = "base_link"   # parent frame
        t.child_frame_id = "lidar_link"   # child frame

        # Translation: lidar is 0.2 m forward, 0.1 m above base
        t.transform.translation.x = 0.2
        t.transform.translation.y = 0.0
        t.transform.translation.z = 0.1

        # Rotation: no rotation (facing same direction as base_link)
        t.transform.rotation.x = 0.0
        t.transform.rotation.y = 0.0
        t.transform.rotation.z = 0.0
        t.transform.rotation.w = 1.0

        self._broadcaster.sendTransform(t)
        self.get_logger().info("Published static transform base_link -> lidar_link")


def main():
    rclpy.init()
    node = StaticFramePublisher()
    rclpy.spin(node)
    rclpy.shutdown()


if __name__ == "__main__":
    main()

💡 Static transforms are published once and latched. Use for sensor mounts and fixed joints — no periodic republishing needed.

Dynamic Transform Broadcaster

Publish time-varying transforms — e.g. robot odometry, moving joints. Must republish at the sensor/control rate.

python
#!/usr/bin/env python3
import rclpy
from rclpy.node import Node
from tf2_ros import TransformBroadcaster
from geometry_msgs.msg import TransformStamped
from nav_msgs.msg import Odometry


class OdomTFBroadcaster(Node):
    def __init__(self):
        super().__init__("odom_tf_broadcaster")
        self._tf_broadcaster = TransformBroadcaster(self)
        self.create_subscription(
            Odometry, "/odom", self._odom_callback, 10
        )

    def _odom_callback(self, msg: Odometry):
        t = TransformStamped()
        # Timestamp MUST match the odometry header stamp
        t.header.stamp = msg.header.stamp
        t.header.frame_id = "odom"
        t.child_frame_id = "base_link"

        t.transform.translation.x = msg.pose.pose.position.x
        t.transform.translation.y = msg.pose.pose.position.y
        t.transform.translation.z = msg.pose.pose.position.z
        t.transform.rotation = msg.pose.pose.orientation

        self._tf_broadcaster.sendTransform(t)


def main():
    rclpy.init()
    node = OdomTFBroadcaster()
    rclpy.spin(node)
    rclpy.shutdown()


if __name__ == "__main__":
    main()

💡 The TF timestamp must match the source data timestamp exactly — mismatches cause TF lookup failures.

Look Up Transforms

Look Up a Transform (Python)

Query the TF buffer for the transform between any two frames and use it to convert a point.

python
#!/usr/bin/env python3
import rclpy
from rclpy.node import Node
from tf2_ros import Buffer, TransformListener, LookupException, ExtrapolationException
from tf2_geometry_msgs import do_transform_point
from geometry_msgs.msg import PointStamped
import rclpy.duration


class TFLookupNode(Node):
    def __init__(self):
        super().__init__("tf_lookup_node")
        self._tf_buffer = Buffer()
        self._tf_listener = TransformListener(self._tf_buffer, self)
        self.create_timer(0.5, self._lookup_timer)

    def _lookup_timer(self):
        try:
            # time=rclpy.time.Time() means "most recent available"
            transform = self._tf_buffer.lookup_transform(
                target_frame="map",
                source_frame="base_link",
                time=rclpy.time.Time(),
                timeout=rclpy.duration.Duration(seconds=1.0)
            )
            pos = transform.transform.translation
            self.get_logger().info(
                f"map<-base_link: x={pos.x:.3f} y={pos.y:.3f}"
            )

            # Transform a point 1 m ahead of the robot into map frame
            pt_in_base = PointStamped()
            pt_in_base.header.frame_id = "base_link"
            pt_in_base.header.stamp = self.get_clock().now().to_msg()
            pt_in_base.point.x = 1.0
            pt_in_base.point.y = 0.0
            pt_in_base.point.z = 0.0

            pt_in_map = do_transform_point(pt_in_base, transform)
            self.get_logger().info(
                f"Point in map: ({pt_in_map.point.x:.2f}, {pt_in_map.point.y:.2f})"
            )

        except (LookupException, ExtrapolationException) as e:
            self.get_logger().warn(f"TF lookup failed: {e}")


def main():
    rclpy.init()
    node = TFLookupNode()
    rclpy.spin(node)
    rclpy.shutdown()


if __name__ == "__main__":
    main()

Install tf2_geometry_msgs: `sudo apt install ros-jazzy-tf2-geometry-msgs`. Required for do_transform_point.

TF from URDF — robot_state_publisher

TF from URDF — robot_state_publisher

robot_state_publisher reads joint states and broadcasts the complete kinematic TF tree automatically.

bash
# Start robot_state_publisher with a URDF
ros2 run robot_state_publisher robot_state_publisher \
  --ros-args -p robot_description:="$(xacro /path/to/robot.urdf.xacro)"

# Publish fake joint states for static visualization
ros2 run joint_state_publisher_gui joint_state_publisher_gui

# Required packages
sudo apt install \
  ros-jazzy-robot-state-publisher \
  ros-jazzy-joint-state-publisher \
  ros-jazzy-joint-state-publisher-gui \
  ros-jazzy-xacro

robot_state_publisher publishes fixed joint transforms to /tf_static and moving joint transforms to /tf.

CLI Debug Tools

TF2 CLI Debug Tools

Debug transform trees and echo live transforms from the terminal.

bash
# View the entire TF tree as a PDF diagram
ros2 run tf2_tools view_frames
# Saves frames.pdf in the current directory

# Echo transforms live between two frames
ros2 run tf2_ros tf2_echo map base_link

# Monitor latency between frames
ros2 run tf2_ros tf2_monitor map base_link

# Inspect TF messages directly
ros2 topic echo /tf
ros2 topic echo /tf_static

# Install tf2 tools if missing
sudo apt install ros-jazzy-tf2-tools ros-jazzy-tf2-ros

Common TF2 Errors & Fixes

ErrorExtrapolationException: Lookup would require extrapolation
FixUse rclpy.time.Time() (latest) instead of a fixed timestamp. Check that use_sim_time matches between publisher and listener.
ErrorLookupException: Frame 'X' does not exist
FixThe broadcaster hasn't published yet. Add try/except with retry, or wait. Run view_frames to see what frames are live.
ErrorTransform direction is wrong
Fixlookup_transform(target='B', source='A') gives the transform FROM A INTO B. Reversal is not negation — swap target and source instead.
ErrorStatic transform not visible in RViz2
FixSet Fixed Frame to a parent frame like 'map'. Static TF is latched — restart the broadcaster if the frame doesn't appear within 5 s.
ErrorTF tree has disconnected islands
FixEvery frame must connect back to a common root (usually 'map'). Use view_frames to spot gaps. Missing odom->base_link is the most common cause.

Related Guides