Skip to content

Repository files navigation

ttdbg

CI Python 3.12+ License: MIT

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, the ttd view web 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.

Why now?

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.

Quick start

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 timeline

In 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).

Choosing what gets recorded

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), or roots= / excludes= in the Python API.
  • Time: with ttdbg.record(...): records just that region; @ttdbg.record records individual calls to one function; ttd run records 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.

Prior art

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

Honest limitations

  • 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=.

Development

python -m venv .venv
.venv\Scripts\python -m pip install -e ".[dev]"
.venv\Scripts\python -m pytest
.venv\Scripts\python scripts\benchmark.py

MIT licensed.

About

A time-travel debugger for Python 3.12+: record once, step backwards, ask when a variable last changed.

Topics

Resources

Stars

Watchers

Forks

Releases

Packages

Contributors

Languages