A time-travel debugger for Python 3.12+. Run your program once while it's being recorded; afterwards, explore the recording like a video — step backwards as well as forwards, inspect any variable at any point in execution, and ask "when did this variable last change?" and jump straight to that moment. Nothing re-executes during exploration; history is just data being queried.
⚠️ Early alpha, MVP complete: recorder,ttd dump, thettd viewweb viewer (timeline scrubbing, backwards stepping, variable last-change jumps, jump-to-exception, call tree), multi-file programs, and the context-manager/decorator API all work. Next up: pytest plugin, DAP adapter, further overhead work.
This has been attempted repeatedly — which proves people want it — and every
attempt died on the same rock: sys.settrace overhead (10–50x slowdowns).
Python 3.12's sys.monitoring
(PEP 669) removed that rock with low-overhead interpreter event hooks. ttdbg
is built on it, and on nothing else — the recorder is pure stdlib.
pip install -e .
ttd run examples/fib.py # run once, recorded → writes ./trace.ttd
ttd view trace.ttd # explore it in the browser
ttd dump trace.ttd --limit 40 # or as a terminal timelineIn the viewer: scrub the timeline, step backwards (←) and forwards
(→), step over calls (Ctrl+←/→), step out (U), jump between exceptions
(E) — and click any variable to jump to the moment it last changed
(clicking again walks further back through its history).
Recording is scoped two ways — in space (which files) and in time (which part of the run):
- Files: by default, only code under your project directory is
recorded; the stdlib, site-packages, and virtualenvs never are. Widen or
narrow with
ttd run --include DIR --exclude PATH script.py(both repeatable, options before the script path), orroots=/excludes=in the Python API. - Time:
with ttdbg.record(...):records just that region;@ttdbg.recordrecords individual calls to one function;ttd runrecords the whole script.
import ttdbg
with ttdbg.record("trace.ttd"): # scope recording to a region
suspicious_function()
@ttdbg.record # or record every call to one function
def flaky_thing(): ...Every headline feature is a query over the trace file (SQLite):
the value of x at event 40,000 is the latest write with event_id <= 40000;
"when did x last change?" is the same query, jumped to.
| Tool | What it proved | Why it isn't this |
|---|---|---|
| rr / Undo | The interaction model is loved (C/C++) | Unusable for normal Python work |
| birdseye, Cyberbrain | Recording Python execution, variable backtracing | Dormant; pre-3.12, built on high-overhead tracing |
| revdb (PyPy) | Real reverse debugging for Python | PyPy-only, gdb-style, obscure |
| PyTrace | This exact pitch, commercially (~2020) | Dead |
| PySnooper / snoop | Demand for recording | Log output, not interactive |
| Replay.io | Custom web viewer > editor debug protocols (JS) | JavaScript |
- CPython 3.12+ only. The whole project is enabled by
sys.monitoring; there is no fallback path, by design. - C extensions are opaque. Code inside numpy internals etc. is invisible to the hooks — you see values before and after the call, not inside it.
- Recording overhead is for debugging, not production. Measured
end-to-end (including writing and indexing the trace file,
python scripts/benchmark.py, Python 3.14 / Windows): a mixed json/re/sorting workload records at ~48x; the absolute worst case — a tight pure-Python arithmetic loop where every executed line is an event — at ~180x. The less of your runtime is line-by-line project Python (i.e. the more realistic the program), the lower the multiple; work continues toward the 2–5x target. The recorder already skips unchanged values by identity, diffs only the names the previous line could touch (per-line bytecode analysis), renders values with a strict node budget, and writes SQLite from a background thread. - Rendered history is bounded, by design. Values are recorded as truncated reprs (≤200 chars, containers elided past ~2 levels / 6 items), not object graphs. A mutation that is elided from an outer container's rendering (too deep / past the item cap) doesn't change that name's recorded value — it is still visible on the closer local it went through. Likewise a mutation made through an alias becomes visible on the other name at the next call/return boundary rather than the very next line.
- Threads, async, and generators as first-class timelines are deferred. Events from all threads are recorded and tagged, but the UX is single-threaded for now.
- By default only files under your project directory are recorded
(stdlib/site-packages are skipped); widen with
roots=.
python -m venv .venv
.venv\Scripts\python -m pip install -e ".[dev]"
.venv\Scripts\python -m pytest
.venv\Scripts\python scripts\benchmark.pyMIT licensed.