PEP 830: Add timestamps to exceptions and tracebacks

This PEP adds an optional timestamp to all exceptions that records when the exception was instantiated with no observable overhead. When enabled via environment variable or command-line flag, formatted tracebacks display this timestamp alongside the exception message.

If you’ve ever debugged a production Python system involving many forms of concurrency and wished you had more information about what happened when, this PEP may be for you. The point at which an exception is logged or rendered with a logging system supplied timestamp is often not the same as the time it happened.

If you never debug complex applications like that, you won’t even notice that this feature exists. It is off by default, to be enabled only by those in environments who feel the above pain. Win win.

14 Likes

I think the time it was raised may be more interesting than the time it was instantiated.

7 Likes

Can this not already be done with something like:

def excepthook(exctype, value, traceback):
    value.add_note(f"Raised at {datetime.now()}")
    sys.__excepthook__(exctype, value, traceback)

sys.excepthook = excepthook

(P.S. It would help if the example in the PEP was self contained so I could try it.)

1 Like

That would be a more intrusive change, though, as the interpreter loop would need to store the info somewhere else until the exception was instantiated when a stateless exception is raised by type.

User code can also force the two times to match by instantiating when raising (which is necessarily the case for stateful exceptions).

I think it can attach the timestamp in do_raise (see here) just after the raised type is instantiated.

I was thinking about cases where the exception is created before the raise. Maybe it’s returned from some function, cached, and raised somewhere else at a later time. It’s also possible to raise the same exception instance more than once. And of course we would need to defined whether the timestamp is overwritten in a reraise (a naked raise or an explicit `raise e’). Setting the timestamp at object creation time bypasses these question because the object is only created once. But I’m questioning why that is the most semantically meaningful timestamp to use here.

3 Likes

How often does code actually have a distinction between time of instantiation and time of raise in practice?

I understand it is possible to do a bare raise SomeExceptionType without an immediately constructing (”message”) argument I believe causes this. But that one doesn’t matter, do_raise immediately calls it to do the instantiation. Nevermind that bare raises without () don’t feel like a normal idiom I’ve seen in code since basically forever. Even in the C API, the PyErr_Set* APIs do the same thing from what I can tell by calling _PyErr_CreateException. It seems like most everything normalizes the exception into an instance pretty quickly.

Does anyone have practical in-use examples of code creating an exception instance well before the raise, or even the unsatisfiable pattern of (eek!) re-using a pre-created exception instance across many disconnected raises? I believe we do this internally for some last-ditch MemoryError situations, but that’s a special case that seems fine to ignore. It is certainly possible for people to write code in Python or C that does it. But why would anyone?

PEP-830 update TODO: We should probably explicitly zero out any singleton exception instance timestamps we have in our own internals.

Regardless, while pondering that I’m going to go brush up on the ceval.h::do_raise path to better understand possibilities.

1 Like

This is also one of those areas where trying to be perfect could be not worthwhile. I expect instantiation time will cover the bulk of use cases. If we find otherwise through experience we could change how the time collection is done.

1 Like

What are the consequences on internal-use exceptions like StopIteration? Querying the time of day takes a notable amount of time:

rosuav@sikorsky:~$ python3 -m timeit -s 'from time import time' 'time()'
10000000 loops, best of 5: 24 nsec per loop
rosuav@sikorsky:~$ python3 -m timeit -s 'from time import time' 'for _ in (): pass'
50000000 loops, best of 5: 5.87 nsec per loop
rosuav@sikorsky:~$ python3 -m timeit -s 'def time(): pass' 'time()'
20000000 loops, best of 5: 11.7 nsec per loop

and I’d hope that this can be avoided in these situations.

did you read the PEP? (ie: StopIteration and StopAsyncIteration needs are answered in there already, yes this was worth considering and is a special case!)

2 Likes

Regarding ceval’s do_raise - that probably isn’t the touchpoint to do it if we wanted time of raise rather than instantiation as it is eval loop focused.

But I think Python/errors.c::_PyErr_SetRaisedException might be. Assuming everything goes through that rather than diddling with tstate->current_exception directly (which feels like a hard assumption to make, though it appears true by my ripgrepping).

1 Like

I have written code which ‘asserts’ a bunch of things by instantiating a exceptions throughout a function, adding them to a list and then raising them as an ExceptionGroup at the end. This removes the extra step of raising the exception each time, catching it, only to add it to an exception group. I’m not sure if this is best practice or anything, but it’s a pattern that works for me.

So if the timestamp is added at raise-time, I think this PEP would no longer cover such exceptions as they are never actually raised. I’m not sure if this is a good or a bad thing, just something to consider.

1 Like

Doh. I did skim it, and I was thinking initially about some others like AttributeError, but then when I went to make the point about the time cost, I switched to StopIteration because it’s easier to demonstrate. So, yes, that particular one IS handled (yay!). What I should have asked is: will things like hasattr calls become slower because of internal time queries?

rosuav@sikorsky:~$ python3 -m timeit -s 'from time import time' 'time()'
10000000 loops, best of 5: 23.8 nsec per loop
rosuav@sikorsky:~$ python3 -m timeit 'hasattr(None, "test")'
20000000 loops, best of 5: 14.4 nsec per loop

Same applies (I think??) to dict.setdefault and __missing__. And maybe I should have stuck with my original thought, since that would have shown me that the time cost isn’t nearly as distinct.

1 Like

By ‘bare raise’ I meant a raise without args (reraise currently handled exception).

I’m not concerned about the case of raising a type, it gets instantiated almost immediately so I don’t see a difference.

About this:

A Python API to toggle timestamps at runtime is unnecessary complexity. Applications that want timestamps are expected to enable them in their environment; a runtime toggle would make it harder to reason about program state.

Could we please not add features to Python that are managed from outside of Python, and can only be managed from outside of Python, unless the nature of the feature necessitates it? I want to program in Python, not in shell script.

4 Likes

The currently handled exception already has a timestamp and the original place it was raised from is the correct place for the time to have been recorded.

Okay so I gather this is what you were talking about in regards to the bare raise that reraises the currently being handled exception as one example. I do not think it is solvable to store a timestamp at every point a raise happens (complex and generally not necessary). It either needs to be time of instantiation, or time of first raise. Time of most recent raise could be far removed from the appropriate spot even in perfectly linear chains of exception handlers doing a reraise after running additional logic. It’s an interesting thought to record each of the re-raises along the way, but no longer trivial.

I think that this idea (adding a timestamp to exceptions) was already discussed previously, but I failed to find the link. Or maybe it was your PR gh-129337 that you created in January 2025.

I found the recent project dttb (created last January) which uses sys.excepthook to add a timestamp to exceptions. It uses datetime.datetime.now() and so has a resolution of 1 microsecond. It formats the timestamp at the beginning:

[2026-01-28 14:30:15.123456+08:00]
Traceback (most recent call last):
  ...
Error: something wrong

I find it interesting to put the timestamp at the start and don’t change str(exc).

Specification:

A new read/write attribute __timestamp_ns__ is added to BaseException. It stores nanoseconds since the Unix epoch

You should mention that’s a timestamp in the UTC time zone.

When timestamps are disabled, or for control flow exceptions (see below), the value is 0.

You should also use the value 0 when reading the clock fails. It’s rare but it can happen when the syscall is blocked by a sandbox (by mistake).

For the Python API (exc.__timestamp_ns__ attribute), would it make sense to return None instead of 0 when the timestamp is not set?

PYTHON_TRACEBACK_TIMESTAMPS environment variable
Set to us or 1 for microsecond-precision decimal timestamps, ns for nanoseconds, or iso for ISO 8601 UTC format. Empty, unset, or 0 disables timestamps (the default).

Can you mention that ISO 8601 format has a resolution of 1 microsecond?

Display Format:

The us format produces <@1776017164.530916> and the ns format produces <@1776017178687320256ns>.

I would prefer to use <@1776017178.687320256> format for ns, similar to the us format but with 3 extra digits after the dot. For example, using this format you can copy/paste the number to datetime.datetime.fromtimestamp() and time.gmtime():

>>> import datetime as dt
>>> print(dt.datetime.fromtimestamp(1776017178.687320256))
2026-04-12 20:06:18.687320

>>> import time
>>> time.gmtime(1776017178.687320256)
time.struct_time(tm_year=2026, tm_mon=4, tm_mday=12, tm_hour=18, tm_min=6, tm_sec=18, tm_wday=6, tm_yday=102, tm_isdst=0)

Performance Measurements.

FYI Linux and other operating systems provide a CLOCK_REALTIME_COARSE clock faster than CLOCK_REALTIME but with a worse resolution. For example, on Linux 6.19.10 with glibc 2.42, I measured in Python that CLOCK_REALTIME has a resolution of 137 nanoseconds, whereas CLOCK_REALTIME_COARSE only has a resolution of 1 ms! I don’t think that 1 ms resolution is enough for exception timestamps. Reading CLOCK_REALTIME_COARSE takes 28.8 ns +- 0.7 ns, whereas reading CLOCK_REALTIME takes 40.7 ns +- 1.6 ns.

TracebackException and the public formatting functions (print_exc, print_exception, format_exception, format_exception_only) gain a no_timestamp keyword argument (default False) that suppresses timestamp display even when globally enabled.

I dislike double negation (ex: no_timestamp=False). Would it be possible to change the parameter to timestamp instead (ex: timestamp=True)?

Rejected Ideas: Millisecond Precision.

Why not also offering the choice to format a timestamp with microsecond resolution? (PYTHON_TRACEBACK_TIMESTAMPS=ms)

2 Likes

The draft PR has been around a while as i’ve been noodling on doing it since early 2025. I presented the idea to the room in our Cambridge Core Team Sprint last September. The feedback there led me to tweaking the behavior a bit (to not collect the times at all when the feature is disabled rather than just controlling their display), better documenting the what and why, and turning it into a PEP.

Using the sys.excepthook singleton does not work for this purpose. It does not get called at the time of raise or construction, which would defeat the purpose for this use case of having every exception raised in an ExceptionGroup carry a timestamp at the point it occurred. It is also global state that other things can (and do) clobber for their own purposes.

FWIW, that dttb library was advertised in a drive by spammy comment on my github tracking issue for this feature a month ago. (unclear if that was by the author or by their AI bot) Regardless it is not an equivalent.

A feeling that it already offers plenty of choices? Supporting this would also be easy and suggests your idea to format the ns value as a decimal has merit as ms us and ns would all be formatted the same that way. Easy to add, but I don’t think it’d be necessary. If anything I could see dropping us support and only offering ns and iso modes (though probably using your decimal format for ns).

For the same reason, I do not want to support custom timestamp formatting strings other than iso. It just adds complexity for no meaningful value.

I thought that was implied by being seconds since the unix epoch like everything in POSIX time land. I’ll add the note to make it clear in my next PEP update.

re: Timer resolution - we always collect a nanosecond resolution value (which most OSes provide) regardless of the display format using PyTime_TimeRaw. Thus the attr being named __timestamp_ns__
See cpython/Objects/exceptions.c at 2672099c67cbeeb03c0dcba55e7975c999887d74 · python/cpython · GitHub

So do I. Now I need to remember why I did that… I think it came from the only reason to ever use the new kwarg at all being to disable the display. But that’s still better as timestamp=False. BUT a timestamp=True default or someone oddly explicitly passing that could be incorrectly interpreted as always displaying one even if collection is not enabled (not possible or it’d be 0) and no need for this to be a tri-state. Which could lead to wordier but accurate API name such as allow_timestamps=False with a default of True. A bit of a bikeshed.