Use C++ for anything inside a hard timing loop: motor control, sensor drivers, point cloud processing, and SLAM back ends. Use Python for everything above that loop: perception prototypes, mission logic, tooling, and ML inference. Most production robots use both, joined by ROS2 topics, services, or a thin binding layer.
Quick Takeaways
- C++ gives deterministic latency, direct memory control, and sub-millisecond loops on a PREEMPT_RT kernel.
- Python cuts development time sharply and owns the ML ecosystem (PyTorch, OpenCV bindings, NumPy).
- Run control loops above ~200Hz, or anything with hard deadlines, in C++. Run planners, behavior trees, and UI glue in Python.
- The best architecture is hybrid: C++
nodesfor the real-time core, Pythonnodesfor high-level logic.
| Criterion | Python (rclpy) | C++ (rclcpp) |
|---|---|---|
| Loop jitter (non-RT Linux) | Typically 1-10 ms | Typically 50-500 µs |
| Max practical control rate | ~100-200Hz | 1kHz+ (with RT kernel) |
| Memory control | Garbage collected | Manual / RAII |
| Prototyping speed | Fast | Slower (compile cycle) |
| ML / vision ecosystem | Excellent | Good (LibTorch, OpenCV) |
| Microcontroller support | MicroPython (limited) | Native (micro-ROS, Arduino, STM32) |
| Learning curve | Low | Steep |
| Debugging | Easy (REPL, pdb) | Harder (gdb, sanitizers) |
Latency figures are typical ranges on commodity hardware. Your numbers depend on CPU, kernel configuration, and message size, so measure on your own target.
Why Execution Model Matters in Robotics
A robot is a closed-loop system. The controller reads state, computes an error, and commands an actuator, all within a fixed period T. If the computation misses its deadline, the effective control gain shifts and the loop can go unstable.
For a discrete PID controller:
e[k] = r[k] - y[k]
u[k] = Kp * e[k] + Ki * T * Σ e[i] + Kd * (e[k] - e[k-1]) / T
The T in the integral and derivative terms assumes a constant sample period. If the real period varies, the computed derivative picks up noise of the form:
Δ(derivative error) ≈ (e[k] - e[k-1]) * (1/T_actual - 1/T_nominal)
A 500Hz loop has a 2 ms budget. If Python’s garbage collector pauses for 5 ms, one sample is lost. On a fast BLDC motor current loop, that is a real fault, not a cosmetic glitch.
Where Python Loses Time
- Global Interpreter Lock (GIL): Threads do not run Python bytecode in parallel, so executor threads in
rclpycontend with each other. - Garbage collection: Pauses are non-deterministic.
- Serialization overhead:
rclpyconverts messages between Python objects and C structs on every callback. - Dynamic dispatch: Each operation costs more than a compiled call.
Where C++ Wins
- Zero-copy transport:
rclcppsupports intra-process communication and loaned messages, which avoids serialization entirely. - Deterministic allocation: Pre-allocate buffers before the loop starts.
- Real-time scheduling: Pair
SCHED_FIFOthreads withmlockall()to prevent page faults. - SIMD and Eigen: Matrix math for kinematics runs several times faster than interpreted NumPy loops on small matrices.
Same Controller, Two Languages
Both examples implement a velocity PID node. The Float64 messages carry measured velocity in and motor command out.
Python (rclpy)
#!/usr/bin/env python3
import rclpy
from rclpy.node import Node
from std_msgs.msg import Float64
class PidNode(Node):
def __init__(self):
super().__init__('pid_py')
# Gains exposed as ROS2 parameters for runtime tuning
self.declare_parameter('kp', 1.2)
self.declare_parameter('ki', 0.4)
self.declare_parameter('kd', 0.02)
self.declare_parameter('setpoint', 1.0) # target velocity, m/s
self.measured = 0.0
self.integral = 0.0
self.prev_error = 0.0
self.dt = 0.01 # 100Hz: realistic ceiling for rclpy
self.create_subscription(Float64, 'measured_velocity', self.on_meas, 10)
self.pub = self.create_publisher(Float64, 'motor_cmd', 10)
self.create_timer(self.dt, self.update)
def on_meas(self, msg: Float64):
self.measured = msg.data # cache latest sensor value
def update(self):
kp = self.get_parameter('kp').value
ki = self.get_parameter('ki').value
kd = self.get_parameter('kd').value
sp = self.get_parameter('setpoint').value
error = sp - self.measured
self.integral += error * self.dt
# Anti-windup: clamp integral to +/-10 to prevent saturation drift
self.integral = max(-10.0, min(10.0, self.integral))
derivative = (error - self.prev_error) / self.dt
self.prev_error = error
out = Float64()
out.data = kp * error + ki * self.integral + kd * derivative
self.pub.publish(out)
def main():
rclpy.init()
rclpy.spin(PidNode())
rclpy.shutdown()
if __name__ == '__main__':
main()
C++ (rclcpp)
#include <chrono>
#include <algorithm>
#include "rclcpp/rclcpp.hpp"
#include "std_msgs/msg/float64.hpp"
using namespace std::chrono_literals;
class PidNode : public rclcpp::Node {
public:
PidNode() : Node("pid_cpp") {
declare_parameter("kp", 1.2);
declare_parameter("ki", 0.4);
declare_parameter("kd", 0.02);
declare_parameter("setpoint", 1.0);
sub_ = create_subscription<std_msgs::msg::Float64>(
"measured_velocity", rclcpp::SensorDataQoS(), // best-effort QoS for low latency
[this](std_msgs::msg::Float64::SharedPtr m) { measured_ = m->data; });
pub_ = create_publisher<std_msgs::msg::Float64>("motor_cmd", rclcpp::SensorDataQoS());
// 1ms period = 1kHz loop; wall timer, so run on an RT-scheduled thread
timer_ = create_wall_timer(1ms, [this]() { update(); });
}
private:
void update() {
const double dt = 0.001;
const double error = get_parameter("setpoint").as_double() - measured_;
integral_ = std::clamp(integral_ + error * dt, -10.0, 10.0); // anti-windup
const double derivative = (error - prev_error_) / dt;
prev_error_ = error;
std_msgs::msg::Float64 out;
out.data = get_parameter("kp").as_double() * error
+ get_parameter("ki").as_double() * integral_
+ get_parameter("kd").as_double() * derivative;
pub_->publish(out);
}
double measured_{0.0}, integral_{0.0}, prev_error_{0.0};
rclcpp::Subscription<std_msgs::msg::Float64>::SharedPtr sub_;
rclcpp::Publisher<std_msgs::msg::Float64>::SharedPtr pub_;
rclcpp::TimerBase::SharedPtr timer_;
};
int main(int argc, char ** argv) {
rclcpp::init(argc, argv);
rclcpp::spin(std::make_shared<PidNode>());
rclcpp::shutdown();
return 0;
}
In the C++ version, get_parameter() inside the loop is a lock-protected lookup. For true hard real-time work, cache gains in member variables and update them from a parameter callback.
Performance by Robotics Subsystem
| Subsystem | Typical Rate | Recommended Language | Reason |
|---|---|---|---|
| Motor current / torque loop | 5-20kHz | C/C++ (MCU firmware) | Hard deadline, no OS |
| Joint position/velocity loop | 500Hz-1kHz | C++ | Jitter-sensitive |
| IMU + wheel odometry EKF | 100-400Hz | C++ | Matrix math, fixed dt |
| LiDAR point cloud filtering | 10-30Hz, 100k+ pts | C++ (PCL) | Memory bandwidth bound |
| Visual detection (YOLO, etc.) | 10-60Hz | Python or C++ | GPU does the work; either works |
| Path planning (global) | 1-5Hz | Python or C++ | Not timing critical |
| Behavior trees / state machines | Event-driven | Python | Logic changes constantly |
| Fleet dashboards, logging, tools | N/A | Python | Library ecosystem |
Kinematics: A Language Comparison in Practice
Take a 2-link planar arm with link lengths L1 and L2. The inverse kinematics solution for target (x, y) is:
D = (x² + y² - L1² - L2²) / (2 * L1 * L2)
θ2 = atan2(±√(1 - D²), D)
θ1 = atan2(y, x) - atan2(L2 * sin(θ2), L1 + L2 * cos(θ2))
Python handles this in four lines of NumPy and is ideal for exploring workspace limits. For a 7-DOF arm solved at 1kHz using the Jacobian pseudo-inverse, you need Eigen in C++:
Δq = J⁺ · Δx, where J⁺ = Jᵀ (J Jᵀ + λ²I)⁻¹ (damped least squares)
The damping term λ avoids singularity blowup near workspace boundaries. Libraries such as KDL, Pinocchio, and MoveIt’s kinematics plugins are all C++ under the hood, and their Python wrappers call the same compiled code.
Building a Hybrid Architecture
Most mature robot stacks look like this:
┌────────────────────────────────────────────────┐
│ Python: mission logic, behavior tree, ML │ 1-30Hz
├────────────────────────────────────────────────┤
│ ROS2 DDS (topics / services / actions) │
├────────────────────────────────────────────────┤
│ C++: SLAM, EKF, planners, ros2_control │ 50-1000Hz
├────────────────────────────────────────────────┤
│ C / C++ firmware (micro-ROS, STM32, ESP32) │ 1-20kHz
└────────────────────────────────────────────────┘
Binding Python to C++ with pybind11
If you need a compiled algorithm callable from a Python script, wrap it:
// kinematics_bind.cpp
#include <pybind11/pybind11.h>
#include <pybind11/eigen.h>
#include <Eigen/Dense>
// Damped least squares step: dq = J^T (J J^T + lambda^2 I)^-1 * dx
Eigen::VectorXd dls_step(const Eigen::MatrixXd& J,
const Eigen::VectorXd& dx,
double lambda) {
Eigen::MatrixXd A = J * J.transpose()
+ lambda * lambda * Eigen::MatrixXd::Identity(J.rows(), J.rows());
return J.transpose() * A.ldlt().solve(dx);
}
PYBIND11_MODULE(kinematics_bind, m) {
m.def("dls_step", &dls_step, "Damped least squares IK step");
}
Python then calls kinematics_bind.dls_step(J, dx, 0.05) with NumPy arrays and pays only a small conversion cost.
Real-World Workflow: Making a Python Prototype Real-Time
A common path for a mobile robot team:
- Prototype in Python. Build the differential-drive controller as an
rclpynode. Tune Kp, Ki, Kd live withros2 param set. - Log and measure. Record a
rosbag2and compute loop-period variance withros2 topic delayandros2 topic hz. - Identify the bottleneck. If period standard deviation exceeds ~10% of nominal, port that node. Profile with
py-spyfirst, since a hot path may be a single loop you can vectorize. - Port the hot node to C++. Keep the same topic names and message types. Python consumers need no changes.
- Harden the C++ node. Install a PREEMPT_RT kernel. Set the control thread to
SCHED_FIFOpriority 80. Callmlockall(MCL_CURRENT | MCL_FUTURE). Pin it to an isolated core withisolcpus. - Validate. Run
cyclictestunder load and confirm worst-case latency stays well under your loop period.
| Stage | Tool | Target Metric |
|---|---|---|
| Prototype | rclpy, ros2 param |
Step response behaves |
| Profile | py-spy, ros2 topic hz |
Jitter < 10% of period |
| Port | rclcpp, ament_cmake |
Identical topic interface |
| Harden | PREEMPT_RT, SCHED_FIFO |
cyclictest max < 100 µs |
Decision Framework
| Your Situation | Choose |
|---|---|
| Hobby robot, Raspberry Pi, <100Hz loops | Python |
| Learning ROS2 fundamentals | Python first, C++ second |
| Vision or ML-heavy pipeline | Python (+ C++ for deployment) |
| Industrial arm, 1kHz joint control | C++ |
| Custom motor drivers, firmware | C/C++ |
| Large point clouds, SLAM back end | C++ |
| Research prototype, algorithm exploration | Python |
| Commercial product with safety requirements | C++ core, Python tooling |
Two additional factors matter. Hiring is one: robotics software roles are weighted heavily toward C++ for production, while research labs lean on Python. Safety certification is the other: standards-driven projects (for example, those following MISRA C++ guidelines) are far easier to justify in C++ than in a garbage-collected runtime.
Common Mistakes
- Running a 1kHz loop in Python. Missed deadlines show up as jittery motors, not error messages.
- Using C++ for everything. Mission logic changes often, and compile cycles slow experiments.
- Ignoring QoS. Both languages need matched
QoSprofiles. Sensor streams should useSensorDataQoS(best-effort). Commands usually need reliable delivery. - Skipping the RT kernel. C++ alone does not guarantee determinism. Without PREEMPT_RT and scheduler tuning, the OS can still preempt your loop.
- Measuring on a desktop. Test on the actual embedded target, because cache sizes and clock governors change results.
FAQ
Is Python fast enough for robotics?
Yes, for high-level tasks. Python handles planning, perception orchestration, and logic at 1-100Hz well, and GPU-accelerated ML runs at native speed because the heavy math happens in compiled libraries. It falls short for hard real-time loops above roughly 200Hz.
Do I need to learn C++ to work with ROS2?
Not to start. rclpy covers nodes, topics, services, actions, and launch files. You will need C++ to write ros2_control hardware interfaces, contribute to core packages like Nav2 or MoveIt internals, or meet strict latency requirements.
Can I use both Python and C++ in the same ROS2 project?
Yes. ROS2 nodes are language-agnostic and communicate over DDS, so a Python node can subscribe to a C++ node‘s topics with no changes. For direct function calls inside one process, use pybind11 or nanobind.
Which language do robotics companies use in production?
Most use C++ for the real-time core and Python for tooling, testing, data pipelines, and ML. Some newer projects add Rust for memory-safe components, but C++ remains dominant because of its library ecosystem (Eigen, PCL, MoveIt, Nav2, ros2_control).




