Can’t subtract times with different clock types in ROS 2 rclpy
What happened when I ran it
I built the same kinds of time objects most rclpy nodes use: Time(), Time(seconds=1.5), Time.from_msg(...), Clock().now() and node.get_clock().now(). Then I subtracted and compared them with each other. Several of those lines raised TypeError: Can't subtract times with different clock types or TypeError: Can't compare times with different clock types. Two of the failing pairs held the same number of nanoseconds. Pairs with very different values went through without complaint.
The cause is a tag, not the value. Every rclpy Time carries a clock_type, and the defaults differ by how you create it. Time() defaults to SYSTEM_TIME. Time.from_msg() and the node’s clock use ROS_TIME. Mix the two in a subtraction or comparison and rclpy raises, whatever the numbers are.
I also reproduced the way this takes down a real node. A timer callback that computed now() - Time() raised on its very first call, and the exception came straight out of spin_once(). When the cached time came from now() instead, all five callbacks completed.
Here are the raw results. The explanation and the fixes come after them.
Environment. Image ros:jazzy-ros-base (ros@sha256:066420e07f60aa18262f2479981def87ebcfcec42eefb0c0c57c4a46098348ca), ROS 2 Jazzy, Python 3.12.3, rclpy 7.1.12-1noble.20260902.053513 (the script itself read 7.1.12), RMW rmw_fastrtps_cpp (default), no network, limits 1 CPU and 1 GiB. A single process creates every node. The script experiment.py (script hash ec92d0d98a882989d78f14e755102c3b4acc3fd3861940c4ec23f16d6b5ef9fb) ran 5 times. All 5 runs exited with code 0 in 1.63 s to 1.65 s and wrote nothing to stderr. Node clock_type_probe reported use_sim_time false and node clock_type_probe_sim reported true.
What each construction path produces (predictions were written in plan.md from the rclpy source before the runs)
| Object | Code | Predicted | Measured (same in all 5 runs unless noted) |
|---|---|---|---|
| A | Time() |
SYSTEM_TIME, 0 | Time(nanoseconds=0, clock_type=SYSTEM_TIME) |
| B | Time(seconds=1.5) |
SYSTEM_TIME, 1500000000 | Time(nanoseconds=1500000000, clock_type=SYSTEM_TIME) |
| B2 | Time(seconds=2, nanoseconds=500000000) |
SYSTEM_TIME, 2500000000 | Time(nanoseconds=2500000000, clock_type=SYSTEM_TIME) |
| C | Time.from_msg(Time(seconds=1.5).to_msg()) |
ROS_TIME, 1500000000 | Time(nanoseconds=1500000000, clock_type=ROS_TIME) |
| D | node.get_clock().now(), use_sim_time false |
ROS_TIME, system-time value | ROS_TIME; run 1: 1791610550303266206 ns |
| E | sim.get_clock().now(), use_sim_time true, before any /clock |
ROS_TIME, 0 | Time(nanoseconds=0, clock_type=ROS_TIME) |
| F | Clock().now() |
SYSTEM_TIME, system time | SYSTEM_TIME; run 1: 1791610550303523582 ns |
| G | Time(seconds=1.5, clock_type=node.get_clock().clock_type) |
ROS_TIME, 1500000000 | Time(nanoseconds=1500000000, clock_type=ROS_TIME) |
Time(seconds=1.5).to_msg() gave sec=1, nanosec=500000000, and Time(seconds=2, nanoseconds=500000000).to_msg() gave sec=2, nanosec=500000000, as predicted. Both nodes’ clocks were ROSClock objects with clock type ROS_TIME. On the sim node, ros_time_is_active was true.
Operations: predicted vs measured (identical outcome in all 5 runs)
| Expression | Predicted | Measured |
|---|---|---|
B == C, B != C, B < C, B <= C, B > C, B >= C |
TypeError | all six: TypeError: Can't compare times with different clock types |
B - C |
TypeError | TypeError: Can't subtract times with different clock types |
B.nanoseconds == C.nanoseconds |
True | True |
D - A (now() minus Time()) |
TypeError | TypeError: Can't subtract times with different clock types |
A < D |
TypeError | TypeError: Can't compare times with different clock types |
A == E (both 0 ns) |
TypeError | TypeError: Can't compare times with different clock types |
A.nanoseconds == E.nanoseconds |
True | True |
F - D (Clock().now() minus node now()) |
TypeError | TypeError: Can't subtract times with different clock types |
C == G |
True | True |
D - C |
Duration | Duration; run 1: 1791610548803266206 ns |
D - E (same ROS_TIME, different time source) |
Duration | Duration; run 1: 1791610550303266206 ns |
B + Duration(seconds=1) |
Time, SYSTEM_TIME kept | Time(nanoseconds=2500000000, clock_type=SYSTEM_TIME) |
C - Duration(seconds=1) |
Time, ROS_TIME kept | Time(nanoseconds=500000000, clock_type=ROS_TIME) |
A == None |
False, no exception | False |
A < None |
plain Python TypeError | TypeError: '<' not supported between instances of 'Time' and 'NoneType' |
Time(1, 500000000) (positional) |
TypeError | TypeError: Time.__init__() takes 1 positional argument but 3 were given |
Time.from_msg(msg, clock_type=ClockType.SYSTEM_TIME) == B |
True | True |
After a /clock message. A publisher node in the same process sent rosgraph_msgs/Clock with sec=42, nanosec=250000000. In every run the sim node received it after 1 published message (wait 0.0 s to 0.001 s). Its now() became Time(nanoseconds=42250000000, clock_type=ROS_TIME). Subtracting Time() from that raised TypeError: Can't subtract times with different clock types. Subtracting C gave a Duration of 40750000000 ns. The plan predicted both outcomes.
Timer callback (the rosbridge pattern). A 0.05 s timer computed tn.get_clock().now() - last_used, and the script called spin_once() until 5 callbacks or 5 s:
Cached last_used |
Predicted | Measured (5 runs) |
|---|---|---|
Time() |
first callback raises, exception leaves spin_once |
callbacks entered 1, completed 0; TypeError: Can't subtract times with different clock types escaped spin_once |
node.get_clock().now() |
callbacks repeat without exceptions | callbacks entered 5, completed 5; no exception; smallest age 50334404 ns to 50381457 ns |
Raw stdout lines from run 1:
{"kind": "op", "label": "B==C", "expr": "B == C", "raised": true, "exception": "TypeError", "message": "Can't compare times with different clock types"}
{"kind": "op", "label": "D-A", "expr": "D - A # now() minus Time()", "raised": true, "exception": "TypeError", "message": "Can't subtract times with different clock types"}
{"kind": "op", "label": "A==E", "expr": "A == E # both 0 ns?", "raised": true, "exception": "TypeError", "message": "Can't compare times with different clock types"}
{"kind": "op", "label": "D-E", "expr": "D - E # same ROS_TIME, different time source", "raised": false, "result": {"type": "Duration", "nanoseconds": 1791610550303266206}}
{"kind": "timer", "variant": "Time() cache", "last_used": {"type": "Time", "clock_type": "SYSTEM_TIME", "nanoseconds": 0, "repr": "Time(nanoseconds=0, clock_type=SYSTEM_TIME)"}, "callbacks_entered": 1, "callbacks_completed": 0, "min_age_ns": null, "exception_escaped_spin_once": {"exception": "TypeError", "message": "Can't subtract times with different clock types"}}
Every outcome matched the prediction from the source. The values that change between runs (D, F, the timer ages) are wall-clock readings. The comparison that matters is clock type, whether an exception was raised, and its message, and those were the same in all 5 runs.
Why it happens: the same class has two different defaults
An rclpy Time is two things together: nanoseconds since an epoch, and the type of clock those nanoseconds came from. The defaults below come from the Jazzy source at commit 4e607c0 (rclpy 7.1.12):
Time.__init__(self, *, seconds=0, nanoseconds=0, clock_type=ClockType.SYSTEM_TIME)(time.py L43–L47). The arguments are keyword-only, andsecondsandnanosecondsare added together.Time.from_msg(msg, clock_type=ClockType.ROS_TIME)(time.py L166–L180).- Every node creates a
ROSClockin its constructor andget_clock()returns it (node.py L222–L223, L349–L351).ROSClockis fixed toROS_TIME(clock.py L340–L345), andnow()stamps the result with the clock’s type (clock.py L212–L216). - A bare
Clock()defaults toSYSTEM_TIME(clock.py L184–L193). use_sim_timeonly turns the ROS time override on or off. It doesn’t change the clock type (time_source.py L87–L93, L115–L120). That’s why D and E were bothROS_TIME.
The check itself is short. __sub__ raises "Can't subtract times with different clock types" when the other operand is a Time with another clock type (time.py L99–L115). __eq__, __lt__, __le__, __gt__ and __ge__ raise "Can't compare times with different clock types" (L117–L127, L129–L155). __ne__ is just not self.__eq__(other), so it raises the same error. That makes seven operators that fail on a mismatch: -, ==, !=, <, <=, >, >=.
Adding or subtracting a Duration never checks anything. The result keeps the clock type of the Time on the left (time.py L85–L94). So Time() + Duration(seconds=5) is still a SYSTEM_TIME value. It will fail later, wherever you compare it against now(), and not on the line where you made the mistake.
The round trip that changes the type
The surprising case is B and C. Time(seconds=1.5) becomes a builtin_interfaces/msg/Time with sec = 1500000000 // 10⁹ = 1 and nanosec = 1500000000 % 10⁹ = 500000000. The message has no field for the clock type. Time.from_msg() rebuilds 1 × 10⁹ + 500000000 = 1500000000 ns, the same value, and attaches its own default, ROS_TIME. The value survives the round trip and the tag doesn’t, so B == C raises. I checked this arithmetic separately in a plain Python example.py (verification.json). That check is arithmetic only and doesn’t run rclpy.
rclpy’s own round-trip test avoids the issue because it builds the original Time as ROS_TIME from the start (test_time.py L191–L202). In my reading, no official test round-trips a default SYSTEM_TIME object.
Even identical clocks underneath are rejected
With use_sim_time off, the ROS clock reports system time. In run 1, D (node clock) and F (Clock().now()) were 1791610550303523582 − 1791610550303266206 = 257376 ns apart. That’s about a quarter of a millisecond, just the gap between the two calls. Both are readings of the same system time, yet F - D raised. rclpy compares the tags and never looks at where the numbers came from. The ROS 2 design article on clocks describes SystemTime and ROSTime as interchangeable, with a runtime check deciding what can be compared. The rclpy implementation turns that check into a hard rejection of any mismatch.
“Time() looks like sim time”: what is actually going on
A Robotics Stack Exchange question from October 2023 (“rclpy.time.Time() vs Node.get_clock().now()”, ROS 2 Humble with Gazebo 11) is a common starting point for this. The asker saw rclpy.time.Time() match the simulator’s /clock while get_clock().now() looked like absolute computer time.
Time() doesn’t read any clock. It is always 0 ns (object A). If a simulation starts paused at time 0, /clock is also 0, so the two match by coincidence. The node’s now() showing wall-clock time suggests that node didn’t have use_sim_time set. A node with use_sim_time true reported 0 before any /clock arrived (object E) and 42.25 s after the test message. That’s my reading of their symptoms. I didn’t have access to their setup. I also checked the Humble, Kilted and Rolling branches of time.py. They have the same two defaults and the same error strings, but I only ran Jazzy.
If you’re coming from ROS 1, rospy.Time.now() corresponds to self.get_clock().now(), not rclpy.time.Time().
A real node that died from this: rosbridge rosapi
This isn’t just a REPL curiosity. In rosbridge_suite, rosapi cached parameter clients with last_used_time set to Time() (params.py L160, before the fix). A cleanup timer then subtracted it from _node.get_clock().now() (L172–L176). The fix commit (aa9a7a3) says the subtraction “raised TypeError every 0.5s”. PR #1308 (merged 2026-08-17) and the Jazzy backport PR #1314 switched the cache timestamp to time.monotonic().
The timer experiment above is a minimal version of that pattern. An exception raised in a timer callback isn’t swallowed by the executor. It propagates out of spin_once(), and with a plain rclpy.spin(node) that normally ends the process. I measured the spin_once case only.
How to fix it
1. For “now”, always ask the node’s clock.
now = self.get_clock().now() # ROS_TIME, follows use_sim_time
2. When you build a time by hand, give it the clock type of whatever you’ll compare it with. This is object G, and C == G returned True:
from rclpy.time import Time
deadline = Time(seconds=1.5, clock_type=self.get_clock().clock_type)
3. For a “not set yet” initial value, don’t use bare Time(). Use None and check for it. Or create the zero with the right type, Time(clock_type=self.get_clock().clock_type). If you only need to know whether a time is zero, compare nanoseconds. A.nanoseconds == E.nanoseconds worked even though A == E raised. The rclcpp maintainers gave the same advice for C++ in rclcpp issue #2113.
self.last_seen = None
...
if self.last_seen is not None:
age = self.get_clock().now() - self.last_seen
4. If you convert a Time to a message and back, say which clock type you want on the way back. Time.from_msg(msg, clock_type=ClockType.SYSTEM_TIME) == B returned True. Better still, create the original from the node clock so the default ROS_TIME is already correct.
5. Stamps from messages are fine to subtract from now(). Time.from_msg(msg.header.stamp) and self.get_clock().now() are both ROS_TIME, so self.get_clock().now() - Time.from_msg(msg.header.stamp) won’t raise.
6. For timeouts and cache lifetimes that have nothing to do with ROS time, consider a monotonic clock. That’s what rosbridge chose. The commit message says measuring the cache TTL monotonically “keeps the mechanism clear of both clock-type mixups and system clock jumps”. This follows from that PR’s design decision and the steady-time use case in the design article. It isn’t a rule for every timer. If something should pause when the simulation pauses, it belongs on ROS time.
When debugging, print the repr. repr(t) shows both parts, for example Time(nanoseconds=0, clock_type=SYSTEM_TIME). One log line usually shows which object came from the wrong path.
A matching clock type doesn’t mean a matching time source
The opposite mistake is quieter. D - E succeeded because both are ROS_TIME. But D came from system time and E from a sim clock at 0. The resulting Duration of 1791610550303266206 ns is about 56.8 years: the full Unix time of the run (2026-10-10 05:35 UTC) minus zero. rclpy only checks the tag. If two nodes disagree about use_sim_time, stamps from one and now() from the other subtract without any error and give nonsense. The fix is a configuration fix: set use_sim_time the same way on every node that shares stamps. Changing the code won’t help.
The same asymmetry in rclcpp
C++ has the same two defaults. rclcpp::Time(int64_t nanoseconds = 0, rcl_clock_type_t clock_type = RCL_SYSTEM_TIME) (time.hpp L54) sits next to a message constructor that defaults to RCL_ROS_TIME (L70–L73). A mismatch throws std::runtime_error("can't compare times with different time sources") (time.cpp L108–L114). I read that source but didn’t run any rclcpp code.
A note on one popular snippet
The ros2_cookbook rclpy time page is a handy conversion table, but its float-to-Time entry uses positional arguments, Time(int(sec), int(nano * 1e9)) (L16). On Jazzy that raised Time.__init__() takes 1 positional argument but 3 were given. Use Time(seconds=..., nanoseconds=..., clock_type=...). The table also never mentions that Time(nanoseconds=t) and Time.from_msg(t) produce different clock types.
Limits of this measurement
- Only rclpy on ROS 2 Jazzy (7.1.12) in a container was run. Statements about Humble, Kilted, Rolling and rclcpp come from reading their source.
- No simulator or robot was involved.
/clockwas a single message published by a node in the same process, not Gazebo output. - The timer test used
spin_once()in a loop. I didn’t measure whatrclpy.spin()or aMultiThreadedExecutordoes with the exception. Time(seconds=x)truncatesx × 10⁹to an integer. The inputs here (1.5, 2 + 0.5) are exact in binary floating point. Other float values may lose a nanosecond, which I didn’t test.- Values like D, F and the timer ages differ on every run. Only clock types, exceptions and the fixed-input nanosecond values are meant to be compared.
Reproduce it
The script, plan and raw results are in experiments/rclpy-time-default-clock-type-from-msg-mismatch. Here are the files pinned to the commit used for this article:
- experiment.py: all objects, 22 operations, the
/clockstage and both timer variants, about 190 lines - plan.md: predictions written before the runs
- results.json: full stdout of all 5 runs and the environment record
From the directory that contains experiment.py:
docker run --rm --network none --cpus 1 --memory 1g -v /path/to/experiment-dir:/work \
ros@sha256:066420e07f60aa18262f2479981def87ebcfcec42eefb0c0c57c4a46098348ca \
bash -c "source /opt/ros/jazzy/setup.bash && python3 /work/experiment.py"
Each run prints one JSON object per line and finishes in under two seconds.
Sources
- rclpy Jazzy time.py:
Timedefaults, operator checks andfrom_msg - rclpy Jazzy clock.py:
Clock,ROSClockandnow() - rclpy Jazzy node.py: the node’s
ROSClockandget_clock() - rclpy Jazzy time_source.py:
use_sim_timehandling - rclpy Jazzy test_time.py: official tests for mismatched comparisons (L148–L160 covers comparisons with non-
Timevalues) - rclpy.time API reference (docs.ros.org, Jazzy path)
- ROS 2 design: Clock and Time, including how ROS time follows system time when no time source is active (L94–L98)
- rclcpp Jazzy time.hpp and time.cpp
- rosbridge_suite PR #1308, PR #1314 and commit aa9a7a3
- rclcpp issue #2113: comparing a zero time against
now() - Robotics Stack Exchange question 104775, “rclpy.time.Time() vs Node.get_clock().now()” (2023-10-17), read through the public Stack Exchange API