A lightweight bridge from Python's sys.monitoring
(PEP 669) to the Tracy profiler, as a plain-C
extension module. Every Python function call becomes a Tracy zone, so you can see
your program's call tree and timing in the Tracy viewer.
sys.monitoring delivers per-frame events; tracypy turns each frame's execution
span into a Tracy zone. Entry events (PY_START / PY_RESUME / PY_THROW) begin a
zone and exit events (PY_RETURN / PY_YIELD / PY_UNWIND) end it, pushed/popped on
a per-thread stack. The callbacks are fast C functions; registration is the only
part done in Python. Tracy is compiled in on-demand mode, so enabling tracypy is
essentially free until a Tracy viewer connects.
The extension is built free-threading-ready (Py_MOD_GIL_NOT_USED), so it works on
the free-threaded (no-GIL) builds of CPython too. Only Python function calls become
zones — work inside C builtins or extension modules doesn't show up as its own zone.
Each thread's zone stack is released when the process exits rather than when the thread
does, so profiling a process that spawns unbounded short-lived threads grows memory
slowly over time; for ordinary worker pools this is a non-issue.
pip install tracypy # or: uv pip install tracypyPrebuilt wheels are published for CPython 3.13 and 3.14 (including the free-threaded 3.14t build) on Linux, macOS, and Windows, with the Tracy client statically linked — no toolchain or submodule needed. There is no source distribution, so on a platform or Python without a matching wheel, install from a Git checkout (below) instead.
Building needs Python ≥ 3.13, a C/C++ toolchain, and CMake. The Tracy client is vendored as a git submodule, so clone recursively (or init the submodule), then install:
git submodule update --init # if you didn't clone with --recurse-submodules
uv pip install . # or: pip install .import tracypy
with tracypy.profile():
my_workload()Or run a script/module under the profiler without editing it:
python -m tracypy my_script.py [args...]
python -m tracypy -m my.module [args...]profile() forwards its tool_id / name arguments to enable(). The low-level
API is tracypy.enable(tool_id=PROFILER_ID, name="tracypy"), tracypy.disable(),
and tracypy.is_enabled(). tracypy.is_connected() reports whether a Tracy
viewer is currently attached — useful since on-demand capture records nothing
until one connects.
A runnable demo lives in examples/demo.py — connect a viewer,
then:
python -m tracypy examples/demo.pyTracy frames delimit recurring units of work, so you can see per-unit timing statistics (min/max/avg, a frame-time graph) on top of the zone call tree. For web apps, for instance, a natural fit is "one frame per request":
import tracypy
# Discontinuous frame: an explicit span with a name of its own timeline.
with tracypy.frame("request"):
handle(request)For a Django app, drop in a middleware:
import tracypy
class TracyFrameMiddleware:
def __init__(self, get_response):
self.get_response = get_response
def __call__(self, request):
with tracypy.frame("request"):
return self.get_response(request)There's also a continuous-frame boundary marker, for the classic game-loop style where every tick is one frame:
tracypy.frame_mark() # default frame boundary
tracypy.frame_mark("render") # a named continuous frameand the bare discontinuous primitives,
tracypy.frame_mark_start(name)tracypy.frame_mark_end(name)
if the context manager doesn't fit.
Frame marks are independent of zone capture — they work whether or not
enable() is on, and are inert until a viewer connects.
Download or build the Tracy profiler UI
and Connect to localhost before (or while) your program runs.
Because Tracy runs on-demand, nothing is captured until you connect.
tracypy vendors the Tracy client v0.13.1, so connect with a matching Tracy 0.13.x viewer — the network protocol is versioned, and a mismatched viewer won't connect.
On a clean exit tracypy flushes the buffered trace to a connected viewer
automatically (via an atexit hook), so even short scripts don't lose their
tail — just keep the viewer connected until the run finishes.