Following system colour scheme Selected dark colour scheme Selected light colour scheme

Python Enhancement Proposals

PEP 830 – Add timestamps to exceptions and tracebacks

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

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_TIMESTAMPS environment variable
Set to ns or 1 for nanosecond-precision decimal timestamps, or iso for ISO 8601 UTC format. Empty, unset, or 0 disables timestamps (the default).
-X traceback_timestamps=<format> command-line option
Accepts the same values. Takes precedence over the environment variable. If -X traceback_timestamps is 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 and ns otherwise.

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_timestamps and a _test_class suffixed variant are decorators that disable timestamp collection for the duration of a test or TestCase class.

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

CPython PR #129337.

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 ns display format from an integer with an ns suffix to seconds with nine decimal digits, allowing direct use with datetime.fromtimestamp(). Dropped the us format; ns is now the default when enabled via 1 or with no explicit format value.
    • Replaced the traceback module no_timestamp parameter with a tri-state timestamps (default None, follow the global setting).
    • Clarified that __timestamp_ns__ is UTC, that clock-read failure yields 0, and how free-list and singleton MemoryError instances are timestamped; that str(exc) and repr(exc) are unchanged; and that the iso display format has microsecond resolution.
    • Noted that hasattr, three-argument getattr, 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 the Traceback header line, returning None when 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.monitoring alternative, and recording time of first raise.
    • Reworked the Motivation example to be self-contained.

Source: https://github.com/python/peps/blob/main/peps/pep-0830.rst

Last modified: 2026-04-20 16:02:45 GMT