PEP 830 – Add timestamps to exceptions and tracebacks
- Author:
- Gregory P. Smith <greg at krypto.org>
- Discussions-To:
- Discourse thread
- Status:
- Draft
- Type:
- Standards Track
- Created:
- 15-Mar-2026
- Python-Version:
- 3.15
- Post-History:
- 12-Apr-2026, 18-Apr-2026
Table of Contents
- Abstract
- Motivation
- Specification
- Rationale
- Backwards Compatibility
- Security Implications
- How to Teach This
- Reference Implementation
- Rejected Ideas
- Using Exception Notes
- Using
sys.excepthook - Using
sys.monitoring - Always Collecting vs. Always Displaying
- Runtime API
- Timestamp in the Traceback Header Line
- More Descriptive
timestampsParameter Name - Custom Timestamp Formats
- Configurable Control Flow Exception Set
- Millisecond Precision
- Separate Millisecond and Microsecond Display Formats
- Returning
NoneWhen Unset - Using a Coarse Clock
- Open Issues
- Acknowledgements
- Change History
- Copyright
Abstract
This PEP adds an optional __timestamp_ns__ attribute to BaseException
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.
Motivation
With the introduction of exception groups (PEP 654), Python programs can now propagate multiple unrelated exceptions simultaneously. When debugging these, or when correlating exceptions with external logs and metrics, knowing when each exception occurred is often as important as knowing what occurred; a common pain point when diagnosing problems in production.
Currently there is no standard way to obtain this information. Python authors must manually add timing to exception messages or rely on logging frameworks, which can be costly and is inconsistently done and error-prone.
Consider an async service that fetches data from multiple backends
concurrently and reports every failure rather than failing fast. The
resulting ExceptionGroup contains all the errors in submission order,
with no indication of when each one occurred:
import asyncio
async def fetch_user(uid):
await asyncio.sleep(0.5)
raise ConnectionError(f"User service timeout for {uid}")
async def fetch_orders(uid):
await asyncio.sleep(0.1)
raise ValueError(f"Invalid user_id format: {uid}")
async def fetch_recommendations(uid):
await asyncio.sleep(2.3)
raise TimeoutError("Recommendation service timeout")
async def get_dashboard(uid):
results = await asyncio.gather(
fetch_user(uid),
fetch_orders(uid),
fetch_recommendations(uid),
return_exceptions=True,
)
errors = [r for r in results if isinstance(r, Exception)]
if errors:
raise ExceptionGroup("dashboard fetch failed", errors)
asyncio.run(get_dashboard("usr_12@34"))
With PYTHON_TRACEBACK_TIMESTAMPS=iso, the output becomes:
+ Exception Group Traceback (most recent call last):
| File "service.py", line 26, in <module>
| asyncio.run(get_dashboard("usr_12@34"))
| ...
| File "service.py", line 24, in get_dashboard
| raise ExceptionGroup("dashboard fetch failed", errors)
| ExceptionGroup: dashboard fetch failed (3 sub-exceptions) <@2026-04-19T07:24:31.102431Z>
+-+---------------- 1 ----------------
| Traceback (most recent call last):
| File "service.py", line 5, in fetch_user
| raise ConnectionError(f"User service timeout for {uid}")
| ConnectionError: User service timeout for usr_12@34 <@2026-04-19T07:24:29.300461Z>
+---------------- 2 ----------------
| Traceback (most recent call last):
| File "service.py", line 9, in fetch_orders
| raise ValueError(f"Invalid user_id format: {uid}")
| ValueError: Invalid user_id format: usr_12@34 <@2026-04-19T07:24:28.899918Z>
+---------------- 3 ----------------
| Traceback (most recent call last):
| File "service.py", line 13, in fetch_recommendations
| raise TimeoutError("Recommendation service timeout")
| TimeoutError: Recommendation service timeout <@2026-04-19T07:24:31.102394Z>
+------------------------------------
The sub-exceptions are listed in submission order, but the timestamps reveal that the order validation actually failed first (at 28.899s), the user service half a second later (at 29.300s), and the recommendation service last after 2.3 seconds (at 31.102s). These can also be correlated with metrics dashboards, load balancer logs, traces from other services, or the program’s own logs to build a complete picture.
Specification
Exception Timestamp Attribute
A new read/write attribute __timestamp_ns__ is added to BaseException.
It stores nanoseconds since the Unix epoch in UTC (same semantics and
precision as time.time_ns()) as a C int64_t exposed via a member
descriptor. When timestamps are disabled, for control flow exceptions (see
below), or if reading the clock fails, the value is 0.
Exception instances reused from an internal free list, such as
MemoryError, are timestamped when handed out rather than when originally
allocated. The interpreter’s last-resort static MemoryError singleton
retains a __timestamp_ns__ of 0.
Control Flow Exceptions
To avoid performance impact on normal control flow, timestamps are not
collected for StopIteration or StopAsyncIteration even when the feature
is enabled. These exceptions are raised at extremely high frequency during
iteration; the check uses C type pointer identity (not isinstance) for
negligible overhead.
Other idioms sometimes described as exceptions-as-control-flow, such as
hasattr, three-argument getattr, dict.get, dict.setdefault,
and __missing__ dispatch, do not need special handling: the hot paths in
CPython’s C internals already return “not found” without instantiating an
exception object.
Configuration
The feature is enabled through CPython’s two standard mechanisms:
PYTHON_TRACEBACK_TIMESTAMPSenvironment variable- Set to
nsor1for nanosecond-precision decimal timestamps, orisofor ISO 8601 UTC format. Empty, unset, or0disables timestamps (the default). -X traceback_timestamps=<format>command-line option- Accepts the same values. Takes precedence over the environment variable.
If
-X traceback_timestampsis specified with no=<format>value, that acts as an implicit=1(nanosecond-precision format).
Consistent with other CPython config behavior, an invalid environment variable
value is silently ignored while an invalid -X flag value is an error.
A new traceback_timestamps field in PyConfig stores the selected format,
accessible as sys.flags.traceback_timestamps.
Display Format
Timestamps are appended to the exception message line in tracebacks using
the format <@timestamp>. This affects formatted traceback output only;
str(exc) and repr(exc) are unchanged. Example with iso:
Traceback (most recent call last):
File "<stdin>", line 3, in california_raisin
raise RuntimeError("not enough sunshine")
RuntimeError: not enough sunshine <@2026-04-12T18:07:30.346914Z>
When colorized output is enabled, the timestamp is rendered in a muted color to keep it visually distinct from the exception message.
The ns format renders seconds since the epoch with nine fractional
digits, for example <@1776017178.687320256>. The iso format renders
at microsecond resolution; the underlying __timestamp_ns__ value is
always nanosecond precision regardless of the display format.
Traceback Module Updates
TracebackException and the public formatting functions (print_exc,
print_exception, format_exception, format_exception_only) gain a
timestamps keyword argument (default None):
None- Follow the global configuration. Timestamps are shown only when the feature is enabled, using the configured format.
False- Never show timestamps, even when the feature is enabled.
True- Show any non-zero
__timestamp_ns__regardless of the global configuration, using the configured format if one is set andnsotherwise.
Only non-zero values are ever rendered. When collection is disabled,
non-zero values can still arise on instances unpickled from a process where
collection was enabled, or where __timestamp_ns__ was assigned directly.
A new utility function traceback.strip_exc_timestamps(text) is provided
to strip <@...> timestamp suffixes from formatted traceback strings.
This is useful for anything that compares traceback output literally.
Doctest Updates
A new doctest.IGNORE_EXCEPTION_TIMESTAMPS option flag is added. When
enabled, the doctest output checker strips timestamps from actual output before
comparison, so that doctests producing exceptions pass regardless of whether
timestamps are enabled.
Third-party projects are not expected to support running their tests with timestamps enabled, and we do not expect many projects would ever want to.
Rationale
The timestamp is stored as a single int64_t field in the BaseException
C struct, recording nanoseconds since the Unix epoch. This design was chosen
over using exception notes (PEP 678) because a struct field costs nothing
when not populated, avoids creating string and list objects at raise time, and
defers all formatting work to traceback rendering. The feature is entirely
opt-in and does not change exception handling semantics.
The use of exception notes as a carrier for the information was deemed infeasible due to their performance overhead and lack of explicit purpose. Notes are great, but were designed for a far different use case, not as a way to collect data captured upon every Exception instantiation.
Instantiation Time vs. Raise Time
The timestamp is recorded when the exception object is created rather than
when it is raised. In the overwhelmingly common raise SomeError(...)
form these are the same moment. Where they differ, instantiation time is
generally the more useful value: a bare raise or raise exc re-raise
preserves the time the error first occurred, not when it was last
re-thrown, and code that constructs exceptions, accumulates them, and later
wraps them in an ExceptionGroup gets the time each error was detected.
Instantiation time is also the cleaner implementation point. Exception instantiation
funnels through BaseException.__init__ and its vectorcall path. Raising
does not have an equivalent single funnel: the lowest-level
_PyErr_SetRaisedException is also invoked by routine save/restore of the
thread’s exception state, and the Python raise statement, C
PyErr_SetObject, and direct PyErr_SetRaisedException calls are
distinct code paths above it.
See Recording Time of First Raise under Open Issues.
Performance Measurements
The pyperformance suite was run on the merge base, on the PR branch with the
feature disabled, and on the PR branch with the feature enabled in both
us and iso modes. These measurements were taken against the
reference implementation matching the original version of this PEP, prior to
the revisions recorded in Change History; those revisions are
simplifications of the display layer only, so no performance difference is
expected.
No significant performance changes were observed: only occasional 1-2% variations that could not be reliably reproduced and fall below the benchmarking setup’s noise threshold.
To validate the control flow special case, the suite was also run with the
two-line StopIteration / StopAsyncIteration exclusion in
Objects/exceptions.c removed. Only a single benchmark,
async_generators, showed a regression, reliably running on the order of
10% slower. It is likely effectively a microbenchmark that does not reflect
most application behavior, but it demonstrates the value of that optimization.
Benchmarks were run against configure --enable-optimizations builds using
commands such as:
pyperformance run -p baseline-3a7df632c96/build/python -o baseline-eopt.json
pyperformance run -p traceback-timestamps/build/python -o traceback-timestamps-default-eopt.json
PYTHON_TRACEBACK_TIMESTAMPS=1 pyperformance run --inherit-environ PYTHON_TRACEBACK_TIMESTAMPS -p traceback-timestamps/build/python -o traceback-timestamps-env=1-eopt.json
PYTHON_TRACEBACK_TIMESTAMPS=iso pyperformance run --inherit-environ PYTHON_TRACEBACK_TIMESTAMPS -p traceback-timestamps/build/python -o traceback-timestamps/Results.silencio/traceback-timestamps-env=iso-eopt.json
PYTHON_TRACEBACK_TIMESTAMPS=1 pyperformance run --inherit-environ PYTHON_TRACEBACK_TIMESTAMPS -p traceback-timestamps-without-StopIter-cases/build/python -o traceback-timestamps/Results.silencio/traceback-timestamps-without-StopIter-cases-env=1-eopt.json
Backwards Compatibility
The feature is disabled by default and does not affect existing exception
handling code. The __timestamp_ns__ attribute is always readable on
BaseException instances, returning 0 when timestamps are not collected.
When timestamps are disabled, exceptions pickle in the traditional 2-tuple
format (type, args). When a nonzero timestamp is present, exceptions
pickle as (type, args, state_dict) with __timestamp_ns__ in the state
dictionary. Older Python versions unpickle these correctly via
__setstate__. Always emitting the 3-tuple form (with a zero timestamp)
would simplify the logic, but was avoided to keep the pickle output
byte-identical when the feature is off and to avoid any performance impact on
the common case. Simpler code is generally preferable, but having every
exception pickle increase in size as a default behavior was judged the
greater risk.
Pickled Exception Examples
With traceback timestamp collection enabled:
$ build/python -X traceback_timestamps=iso -c 'import pickle; print(pickle.dumps(RuntimeError("pep-830"), protocol=pickle.HIGHEST_PROTOCOL))'
b'\x80\x05\x95L\x00\x00\x00\x00\x00\x00\x00\x8c\x08builtins\x94\x8c\x0cRuntimeError\x94\x93\x94\x8c\x07pep-830\x94\x85\x94R\x94}\x94\x8c\x10__timestamp_ns__\x94\x8a\x08\xf4\xd8\x94`\x15\xaf\xa5\x18sb.'
The special case for StopIteration means it does not carry the dict with timestamp data:
$ build/python -X traceback_timestamps=iso -c 'import pickle; print(pickle.dumps(StopIteration("pep-830"), protocol=pickle.HIGHEST_PROTOCOL))'
b'\x80\x05\x95,\x00\x00\x00\x00\x00\x00\x00\x8c\x08builtins\x94\x8c\rStopIteration\x94\x93\x94\x8c\x07pep-830\x94\x85\x94R\x94.'
Nor do exceptions carry the timestamp when the feature is disabled (the default):
$ build/python -X traceback_timestamps=0 -c 'import pickle; print(pickle.dumps(RuntimeError("pep-830"), protocol=pickle.HIGHEST_PROTOCOL))'
b'\x80\x05\x95+\x00\x00\x00\x00\x00\x00\x00\x8c\x08builtins\x94\x8c\x0cRuntimeError\x94\x93\x94\x8c\x07pep-830\x94\x85\x94R\x94.'
Which matches what Python 3.13 produces:
$ python3.13 -c 'import pickle; print(pickle.dumps(RuntimeError("pep-830"), protocol=pickle.HIGHEST_PROTOCOL))'
b'\x80\x05\x95+\x00\x00\x00\x00\x00\x00\x00\x8c\x08builtins\x94\x8c\x0cRuntimeError\x94\x93\x94\x8c\x07pep-830\x94\x85\x94R\x94.'
Maintenance Burden
The __timestamp_ns__ field is a single int64_t in the BaseException
C struct, present in every exception object regardless of configuration. The
collection code is a guarded clock_gettime call; the formatting code only
runs at traceback display time. Both are small and self-contained.
The main ongoing cost is in the test suite. Tests that compare traceback output literally need to account for the optional timestamp suffix. Two helpers are provided for this:
traceback.strip_exc_timestamps(text)strips<@...>suffixes from formatted traceback strings.test.support.force_no_traceback_timestampsand a_test_classsuffixed variant are decorators that disable timestamp collection for the duration of a test orTestCaseclass.
Outside of the traceback-specific tests, approximately 14 of ~1230 test files
(roughly 1%) needed one of these helpers, typically tests that capture
stderr and match against expected traceback output (e.g. test_logging,
test_repl, test_wsgiref, test_threading). The pattern follows the same approach used by force_not_colorized for
ANSI color codes in tracebacks.
Outside of CPython’s own CI, where timestamps are enabled on a couple of GitHub Actions runs to maintain coverage, most projects are unlikely to have the feature enabled while running their test suites.
Security Implications
None. The feature is opt-in and disabled by default.
How to Teach This
The __timestamp_ns__ attribute and configuration options will be documented
in the exceptions module reference, the traceback module reference,
and the command-line interface documentation.
This is a power feature: disabled by default and invisible unless explicitly enabled. It does not need to be covered in introductory material.
Reference Implementation
Rejected Ideas
Using Exception Notes
Using PEP 678’s .add_note() to attach timestamps was rejected for
several reasons. Notes require creating string and list objects at raise time,
imposing overhead even when timestamps are not displayed. Notes added when
catching an exception reflect the catch time, not the raise time, and in
async code this difference can be significant. Not all exceptions are caught
(some propagate to top level or are logged directly), so catch-time notes
would be applied inconsistently. A struct field captures the timestamp at the
source and defers all formatting to display time.
Using sys.excepthook
sys.excepthook runs only when an uncaught exception reaches the top
level, at display time rather than when the exception was created. For the
motivating example above, the hook would fire once for the resulting
ExceptionGroup after all tasks have completed, so every sub-exception
would receive the same timestamp. Exceptions that are caught and logged
never reach the hook at all.
Using sys.monitoring
An equivalent feature could in principle be built on PEP 669 as a
third-party add-on without interpreter changes: register a C-implemented
callable for sys.monitoring.events.RAISE that reads the clock and either
sets a __timestamp_ns__ attribute on the exception or calls
add_note(). STOP_ITERATION and RERAISE are separate events, so
subscribing only to RAISE avoids the iterator hot path and naturally
preserves the original timestamp on re-raise.
This is expected to cost more than the struct-field approach. RAISE
fires once per Python frame during unwind rather than once per exception, so
the callback runs more often than the task requires, and each invocation is
dispatched through vectorcall, not called directly. Without a struct
field, storing the value allocates the exception’s instance __dict__
plus a PyLongObject for the timestamp, or for add_note() the dict
plus a __notes__ list and formatted string. One of the limited
monitoring tool IDs is also consumed. The add_note() variant renders in
tracebacks without further integration; the __timestamp_ns__ variant
would also need the sys.excepthook singleton, which other code may be
using, or a monkeypatch of the traceback module to display the value.
Allocating a list, a string, and potentially an instance dict on every raise
in a program felt extreme enough overhead-wise that this has not been tried.
A middle ground would use sys.monitoring only for collection while
keeping this PEP’s int64_t struct field on BaseException and the
traceback module’s display of any non-zero __timestamp_ns__. That
removes the conditional clock read from the BaseException constructor,
giving a tiny saving when the feature is off, at the cost of notably more
overhead when it is on.
Always Collecting vs. Always Displaying
Collecting timestamps (a clock_gettime call during instantiation) and
displaying them in formatted tracebacks are separate concerns.
Always displaying was rejected because it adds noise that most users do not
need. Always collecting (even when display is disabled) is cheap since the
int64_t field exists in the struct regardless, but not collecting avoids
any potential for performance impact when the feature is turned off, and there
is no current reason to collect timestamps that will never be shown. This could be
revisited if programmatic access to exception timestamps becomes useful
independent of traceback display.
Runtime API
This is an operator-level setting, intended to be configured by whoever launches the process rather than by library or application code. Exposing a runtime toggle for process-wide state invites different parts of a program to fight over its value; keeping it fixed at startup avoids that. It has been omitted for now to keep things simple. If there is demand for runtime configurability, nothing blocks adding that at a later date.
Timestamp in the Traceback Header Line
Placing the timestamp in the Traceback (most recent call last): header
was suggested as less disruptive to tools that parse the Type: message
line. Each link in a __cause__ or __context__ chain, and each
sub-exception in an ExceptionGroup, does get its own header, so this
covers the common case. However, the header is only emitted when the
exception has a __traceback__; an exception constructed and attached
directly renders as just Type: message with no header. The standard
library itself does this: concurrent.futures.ProcessPoolExecutor and
multiprocessing.Pool attach a constructed _RemoteTraceback as
__cause__ to carry a worker’s formatted traceback across the process
boundary, and it renders with no header. Appending to the message line is
the only placement guaranteed to render for every displayed exception.
There is also a reading-order argument: when scanning logs for errors,
people typically search for the exception type, land on the
Type: message line, and read upward through the frames. Putting the
timestamp on that line places it where the eye lands first rather than at
the top of a block that is often read last.
More Descriptive timestamps Parameter Name
Earlier drafts named the traceback formatting parameter no_timestamp,
a boolean which read as a double negative. Longer alternatives such as
allow_timestamps or show_timestamp were also considered. The short
positive form timestamps was chosen as a tri-state defaulting to None
(follow the global configuration), with the docstring and documentation
covering the exact behavior.
Custom Timestamp Formats
User-defined format strings would add significant complexity. The two
built-in formats (ns, iso) cover the common needs: decimal seconds
for programmatic use and ISO 8601 for correlation with external systems.
Configurable Control Flow Exception Set
Allowing users to register additional exceptions to skip was rejected. The
exclusion check runs in the hot path of exception creation and uses C type
pointer identity for speed. Supporting a configurable set would require
either isinstance checks (too slow, walks the MRO) or a hash set of
type pointers (complexity with unclear benefit). StopIteration and
StopAsyncIteration are the only exceptions raised at frequencies where
the cost of clock_gettime is measurable. If a practical need arises, an
API to register additional exclusions efficiently could be added as a follow-on
enhancement.
Millisecond Precision
Nanosecond precision was chosen over millisecond to match time.time_ns()
and to provide sufficient resolution for high-frequency exception scenarios.
Separate Millisecond and Microsecond Display Formats
Earlier drafts offered a us display format alongside ns, and a
ms format was also suggested. These differed from ns only in the
number of fractional digits shown. Since the decimal output is intended for
machine consumption, truncating precision provides no real benefit, so only
the full-precision ns format is offered. Users wanting a
human-readable form should use iso.
Returning None When Unset
Returning None rather than 0 when no timestamp was collected would be
slightly more Pythonic, but __timestamp_ns__ is exposed as a plain
int64_t member descriptor on the C struct. Supporting None would
require a custom getter and boxed storage. 0 is unambiguous.
Using a Coarse Clock
The reference implementation uses PyTime_TimeRaw, which reads the
standard wall clock at full resolution; benchmarking shows this cost is not
observable in practice. A coarse clock such as CLOCK_REALTIME_COARSE
would be low resolution, insufficient for many needs, for no measurable
benefit.
Open Issues
Display Location
The current specification appends the timestamp to the Type: message
line. Placing it in the Traceback (most recent call last): header
instead, or on a separate line, has been proposed; see Timestamp in the
Traceback Header Line for the reasoning behind the current choice. This
is open to revision. Making the location configurable is also possible, but
every knob added is more complexity to support.
A concern was raised that existing code parses the Type: message line
and would be disrupted by a suffix. The same concern applies to the
Traceback header: doctest traceback matchers and CPython’s own
test.support helpers already needed adjusting in the reference
implementation, though such cases were rare.
Benchmarking the sys.monitoring Alternative
See Using sys.monitoring under Rejected Ideas for the design and its expected costs. If there is a strong preference for avoiding interpreter changes despite that analysis, a prototype and benchmark could be produced to settle the question.
Recording Time of First Raise
Recording the time of first raise, in place of or in addition to instantiation time, was suggested. This is deferred rather than rejected: it would cover the case where an exception is constructed well in advance of being raised, but it requires identifying a clean hook point in the interpreter’s raise paths (see Instantiation Time vs. Raise Time) and defining the semantics for re-raise. We are not aware of code commonly using a pattern of constructing an exception well before its first use.
Acknowledgements
Thanks to Nathaniel J. Smith for the original idea suggestion, and to Daniel Colascione for initial 2025 review feedback on the implementation.
Change History
- 18-Apr-2026
- Changed the
nsdisplay format from an integer with annssuffix to seconds with nine decimal digits, allowing direct use withdatetime.fromtimestamp(). Dropped theusformat;nsis now the default when enabled via1or with no explicit format value. - Replaced the
tracebackmoduleno_timestampparameter with a tri-statetimestamps(defaultNone, follow the global setting). - Clarified that
__timestamp_ns__is UTC, that clock-read failure yields0, and how free-list and singletonMemoryErrorinstances are timestamped; thatstr(exc)andrepr(exc)are unchanged; and that theisodisplay format has microsecond resolution. - Noted that
hasattr, three-argumentgetattr, and the dict miss paths do not instantiate exceptions in CPython’s hot paths and need no special handling. - Added a Rationale subsection on instantiation time vs. raise time.
- Added Rejected Ideas entries for
sys.excepthook,sys.monitoring, placing the timestamp in theTracebackheader line, returningNonewhen unset, separate ms/us display formats, and using a coarse-resolution clock; reworded the Runtime API rejection. - Added an Open Issues section covering display location, benchmarking the
sys.monitoringalternative, and recording time of first raise. - Reworked the Motivation example to be self-contained.
- Changed the
Copyright
This document is placed in the public domain or under the CC0-1.0-Universal license, whichever is more permissive.
Source: https://github.com/python/peps/blob/main/peps/pep-0830.rst
Last modified: 2026-04-20 16:02:45 GMT