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
Co-Founder
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:
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
ROS2 on Mac or Windows Means Docker. That Sentence Costs More Than Most Teams Realize.
ROS2 is Linux-native. That is not a complaint about the design choice — Linux is the right foundation for production robotics. The complaint is about what the development toolchain assumes: that every
May 26, 2026
Your ROS2 Subscriber Is Not Receiving Messages. No Error. No Warning. Nothing.
Your publisher is running. `ros2 topic echo` shows data flowing out. Your subscriber node is alive. The callback never fires. No error in the log. No warning on the console. Nothing. This is a QoS mis
May 26, 2026
10 Lines in ROS1. 50 in ROS2. The Launch File Problem Nobody Warned You About.
A developer on Reddit described migrating a real project from ROS1 to ROS2. The launch file went from 10 lines to 50. Same nodes. Same topics. Same robot. That is a real measurement from a real migrat
May 25, 2026