Back to blog
ROS2Architecture

Python ROS2 Nodes Start Fine. Then the Callbacks Start Drifting.

Most ROS2 teams start in Python. The API is cleaner. Iteration is faster. rclpy handles the majority of nodes without issue. Then at some point a timer fires late, a high-frequency publisher drops mes

José González

José González

Co-Founder

·5 min read

Most ROS2 teams start in Python. The API is cleaner. Iteration is faster. rclpy handles the majority of nodes without issue. Then at some point a timer fires late, a high-frequency publisher drops messages, and you are reading the Global Interpreter Lock documentation at 11pm.

The split is not about language preference. It is about what breaks and when.

Where the Line Actually Is

In practice, the division looks like this:

  • Write Python for: launch files, testing with launch_testing, diagnostic scripts, parameter tuning nodes, anything that runs below 20Hz and does not touch hardware directly.
  • Write C++ for: hardware interfaces in ros2_control, timing-critical loops, high-frequency publishers above ~50Hz, anything that shares data between callbacks without wanting to think about the GIL, Nav2 and MoveIt plugins.

The line is fuzzy. A Python subscriber at 10Hz is fine. The problem is not speed. The problem is reasoning about what happens under load.

The GIL Is Not a Theoretical Problem

Python's Global Interpreter Lock means that only one thread executes Python bytecode at a time. rclpy provides a MultiThreadedExecutor and callback groups, same as rclcpp. The API looks identical. The behavior is not.

In C++, a MultiThreadedExecutor with proper callback groups runs callbacks on different threads in parallel. In Python, the GIL serializes them. Your mental model says "these two callbacks run concurrently." The reality is that they take turns, and the handoff is not deterministic.

The result: a callback that should fire every 100ms starts drifting. A publisher at 50Hz drops to 45Hz under CPU load. A subscriber that processes sensor data misses the deadline for the control loop because another Python callback is holding the GIL.

These failures do not crash. They degrade. The system keeps running, just slightly worse than it should, and you notice only when the robot starts behaving oddly.

A Concrete Example: The Drifting Timer

Here is a timer callback that should fire every 100ms:

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

class DriftNode(Node):
    def __init__(self):
        super().__init__('drift_node')
        self.timer = self.create_timer(0.1, self.timer_callback)
        self.sub = self.create_subscription(
            String, '/heavy_topic', self.sub_callback, 10)
        self.last_time = self.get_clock().now()

    def timer_callback(self):
        now = self.get_clock().now()
        dt = (now - self.last_time).nanoseconds / 1e6  # ms
        self.get_logger().info(f'Timer delta: {dt:.1f} ms')
        self.last_time = now

    def sub_callback(self, msg):
        # Simulate CPU-intensive processing
        for _ in range(10_000_000):
            pass

def main():
    rclpy.init()
    node = DriftNode()
    rclpy.spin(node)

if __name__ == '__main__':
    main()

Run this with a MultiThreadedExecutor and watch the timer delta. When /heavy_topic publishes, the subscriber callback grabs the GIL and holds it for the entire processing loop. The timer callback waits. The 100ms interval stretches to 200ms, 300ms, or worse. The timer does not skip a beat — it delays. Your control loop thinks it is running at 10Hz when it is actually running at 3Hz.

In C++, the same pattern with a MultiThreadedExecutor and a Reentrant callback group keeps the timer on one thread and the subscriber on another. Both run in parallel. No drift.

Executor Behavior Under Load Is Harder to Reason About in Python

The MultiThreadedExecutor in rclpy is not fake. It does use multiple threads. The issue is that all threads compete for the same GIL. Under light load, you rarely notice. Under heavy load — multiple sensor callbacks, image processing, point cloud transforms — the contention becomes real.

C++ gives you explicit control. You can put the timer in one MutuallyExclusive group, the subscriber in another, and know exactly what runs when. Python gives you the same API but the GIL adds a hidden serialization layer that is not in your code and not in the documentation.

Senior engineers on most teams I know write production logic in C++ and use Python for everything else. Not because they prefer C++. Because the failure modes are easier to find when something goes wrong.

When Python Is the Right Choice

Python is not the wrong choice for most ROS2 nodes. It is the wrong choice for nodes where timing matters under load.

Use Python for:

  • Prototyping and parameter tuning
  • Launch file orchestration
  • Diagnostic and monitoring scripts
  • Low-frequency subscribers that do not block
  • Nodes that call out to external Python libraries (ML inference, data processing)

Use C++ for:

  • Hardware interfaces where microseconds matter
  • Control loops above 50Hz
  • Any node where multiple callbacks must not block each other
  • Safety-critical code where a missed deadline is a system failure

The real mistake is assuming the choice does not matter because the API is the same. The API is the same. The runtime is not.

What to Take Away

The Python vs C++ debate in ROS2 is usually framed as a speed question. It is not. It is a predictability question. C++ gives you explicit control over concurrency, timing, and memory. Python gives you convenience at the cost of a hidden serialization layer that becomes visible only when the system is under stress.

When you need to reason about what happens if two callbacks fire at the same time, Python stops being the easy answer. The GIL is not a bug you can fix. It is a constraint you have to design around.

Continue reading

Related Posts