Intermediate

You run your Python script, it takes longer than expected, and you have no idea where the time is going. Adding print(time.time()) calls everywhere is slow, noisy, and still leaves you guessing which nested function call is the real culprit. cProfile gives you totals, but when a function is called 10,000 times from 50 different places, a flat table of numbers does not show you the story of how your program actually ran.

This is exactly what viztracer was built to solve. It records every function call, return, and exception in your program and saves the timeline to a JSON file that you can open as an interactive flame graph in your browser. You can zoom, filter, and inspect individual microsecond-level spans — all without adding a single line of instrumentation code to your program.

In this article we will cover how to install viztracer and trace your first script from the command line, how to use the context manager and decorator for targeted tracing, how to filter noisy built-in calls, how to add custom events and variables to the trace, and how to build a practical profiling workflow around a real script. By the end you will be able to open any Python program and see exactly where execution time is being spent, down to the function call level.

Tracing a Script with viztracer: Quick Example

The fastest way to get started is the command-line runner. Install viztracer, point it at your script, and open the result.

# install viztracer
pip install viztracer
# quick_trace.py
import time

def slow_step():
    time.sleep(0.05)

def fast_step():
    return sum(range(10_000))

def main():
    for _ in range(3):
        slow_step()
    result = fast_step()
    print(f"Result: {result}")

if __name__ == "__main__":
    main()

Now trace it:

viztracer quick_trace.py
Result: 49995000
viztracer: Saving trace data to result.json ...
viztracer: Trace saved. File size: 42.3 KB
viztracer: Open with: vizviewer result.json

Open the trace in your browser:

vizviewer result.json

A browser tab opens at http://localhost:9001 showing a flame graph. Each horizontal bar is a function call — wider bars took longer. The three slow_step calls will be immediately visible as wide spans at the top, with time.sleep beneath them. fast_step will appear as a narrow bar, showing that sum(range(...))) is fast.

That is the core workflow: trace, view, find the bottleneck. The sections below show you how to control exactly what gets recorded and how to add your own annotations to the trace.

Developer studying a colorful performance timeline flame graph
cProfile gave you a number. viztracer shows you the story.

What Is viztracer and Why Use It?

viztracer is a low-overhead Python tracing tool that records the precise start and end time of every function call your program makes and writes that data to a JSON file following the Perfetto trace format. The viewer — either vizviewer locally or https://ui.perfetto.dev/ in the browser — renders the data as an interactive flame graph where:

  • The x-axis is time — events further right happened later
  • The y-axis is call depth — each row is one level of the call stack
  • Bar width represents duration — wider means slower

This is fundamentally different from a flat profiler like cProfile, which only tells you aggregated totals. viztracer shows you the execution sequence — which function called which, in which order, and how long each call in context took. The table below compares the two approaches:

FeaturecProfileviztracer
Output typeFlat call-count tableInteractive flame graph timeline
Call sequence visibleNoYes
Per-call timingTotals onlyEvery individual call
OverheadLowLow (C extension, ~2-3x slowdown)
Filter noisy callsLimitedYes (max_stack_depth, ignore_frozen)
Custom eventsNoYes (add_instant, log_var)
Async/thread supportLimitedYes
Output formatpstats / textPerfetto JSON (browser-compatible)

viztracer uses a C extension to intercept Python’s sys.setprofile at a low level, keeping overhead manageable. For most programs you can expect a 2-3x slowdown during tracing — fast enough that your program still runs in a reasonable time, slow enough that you should not leave tracing on in production.

Installation and Setup

viztracer requires Python 3.6 or later. Install it from PyPI:

# install_viztracer.sh
pip install viztracer

Verify the install:

viztracer --version
viztracer 1.0.x

On systems where viztracer is not on your PATH after installation, use python -m viztracer instead of the bare command. Both forms accept the same arguments.

Command-Line Tracing

The CLI is the easiest entry point. You do not need to modify your source file at all — viztracer wraps the script and instruments it automatically.

Basic Trace

Run any Python script through viztracer by placing it after the viztracer command, followed by any arguments your script normally receives:

# trace_cli.sh
viztracer my_script.py arg1 arg2
# Or if the command isn't on PATH:
python -m viztracer my_script.py arg1 arg2
viztracer: Saving trace data to result.json ...
viztracer: Trace saved. File size: 1.8 MB

The output file is always result.json in the current directory unless you specify otherwise with -o. For runs you want to keep, name the output explicitly:

# named_output.sh
viztracer -o traces/run_2026-07-21.json my_script.py

Limiting Depth to Reduce Noise

Large programs — especially those using libraries like NumPy or Django — generate thousands of short internal calls that clutter the trace. The --max_stack_depth flag records only calls up to a given nesting depth:

# depth_limited.sh
viztracer --max_stack_depth 10 my_script.py

A depth of 10 usually captures all of your application logic while filtering out deep library internals. If you still see too much noise, pair this with --ignore_frozen to skip modules loaded from frozen (.pyc) bytecode:

# ignore_frozen.sh
viztracer --max_stack_depth 10 --ignore_frozen my_script.py
viztracer: Saving trace data to result.json ...
viztracer: File size: 0.3 MB (was 1.8 MB without filters)

The filtered trace will be 5-10x smaller and much easier to navigate in the viewer.

Developer using magnifying glass to zoom in on a highlighted call tree branch
–max_stack_depth 10. Because you came here to profile your code, not NumPy’s.

Using viztracer as a Context Manager

When you only want to trace a specific section of a larger program — not the entire startup and teardown — use the VizTracer context manager. Import it, wrap the target block, and the output file is written automatically when the with block exits.

# context_manager.py
import time
from viztracer import VizTracer

def prepare_data():
    """Simulate a setup step we do NOT want to trace."""
    time.sleep(0.1)
    return list(range(1000))

def process(data):
    return [x * x for x in data]

def save_results(results):
    total = sum(results)
    print(f"Total: {total}")

data = prepare_data()   # not traced

with VizTracer(output_file="process_only.json"):
    results = process(data)  # traced

save_results(results)   # not traced
Total: 332833500
viztracer: Saving trace data to process_only.json ...

Only the process(data) call and everything it calls internally appear in the trace. The prepare_data() and save_results() calls are invisible. This is the right approach when your application has a long initialization phase that would swamp the trace data you actually care about.

You can also pass configuration options directly to the constructor:

# context_manager_options.py
from viztracer import VizTracer

with VizTracer(
    output_file="filtered.json",
    max_stack_depth=8,
    ignore_frozen=True,
    log_gc=False,          # exclude garbage collector events
    min_duration=100,      # microseconds -- skip calls shorter than 100 us
):
    my_heavy_function()

The min_duration filter is particularly useful: it removes all the tiny one-microsecond function calls that are technically accurate but visually irrelevant, leaving only the spans that actually matter to your investigation.

Using the @trace_and_save Decorator

When you want to trace a single function every time it is called, without wrapping every call site in a with block, use the @trace_and_save decorator from viztracer:

# decorator_example.py
from viztracer import trace_and_save
import time

@trace_and_save(output_file="sort_trace.json")
def merge_sort(arr):
    """Classic recursive merge sort."""
    if len(arr) <= 1:
        return arr
    mid = len(arr) // 2
    left = merge_sort(arr[:mid])
    right = merge_sort(arr[mid:])
    return merge(left, right)

def merge(left, right):
    result = []
    i = j = 0
    while i < len(left) and j < len(right):
        if left[i] <= right[j]:
            result.append(left[i])
            i += 1
        else:
            result.append(right[j])
            j += 1
    return result + left[i:] + right[j:]

import random
data = random.sample(range(10_000), 500)
sorted_data = merge_sort(data)
print(f"Sorted {len(sorted_data)} elements")
Sorted 500 elements
viztracer: Saving trace data to sort_trace.json ...

When you open this trace you will see the recursive call tree of merge_sort as a deep nested flame graph -- each recursive call spawning two children below it. This makes the O(n log n) behavior of merge sort immediately visible as a visual structure, which is a fantastic teaching tool as well as a debugging aid.

Developer staring at a pyramid-shaped recursive flame graph
O(n log n) looks different when you can actually see the n log n.

Adding Custom Events and Variables

Sometimes raw function timings are not enough -- you also want to record what values your variables held at key points in the trace. viztracer provides two methods on the tracer object: add_instant for point-in-time events and log_var for recording a variable's value.

# custom_events.py
from viztracer import VizTracer
import time

def process_batch(tracer, batch_id, items):
    tracer.add_instant(
        name=f"batch_start",
        args={"batch_id": batch_id, "size": len(items)},
    )
    result = []
    for item in items:
        processed = item ** 2
        result.append(processed)
    tracer.log_var("batch_result_sum", sum(result))
    return result

with VizTracer(output_file="custom_events.json") as tracer:
    all_results = []
    for i, batch in enumerate([[1,2,3], [4,5,6], [7,8,9]]):
        batch_output = process_batch(tracer, i, batch)
        all_results.extend(batch_output)

print(f"Final sum: {sum(all_results)}")
Final sum: 285
viztracer: Saving trace data to custom_events.json ...

In the flame graph viewer, add_instant events appear as vertical markers on the timeline -- thin lines you can click to see the args dictionary. log_var results appear as a separate row below the call stack showing how the variable's value changed over time. Both are invaluable when you need to correlate a performance spike with a particular input state.

Tracing Async and Multi-threaded Code

viztracer handles asyncio coroutines and threads without any special configuration. Async tasks appear as interleaved spans in the timeline -- you can see exactly when a coroutine yielded to the event loop and when it was resumed. Threads each get their own row in the flame graph, making it easy to spot thread synchronization delays.

# async_trace.py
import asyncio
import time
from viztracer import VizTracer

async def fetch_page(url_id):
    # Simulate variable-latency I/O
    await asyncio.sleep(0.02 * (url_id % 3 + 1))
    return f"page_{url_id}_content"

async def main():
    tasks = [fetch_page(i) for i in range(6)]
    results = await asyncio.gather(*tasks)
    print(f"Fetched {len(results)} pages")

with VizTracer(output_file="async_trace.json"):
    asyncio.run(main())
Fetched 6 pages
viztracer: Saving trace data to async_trace.json ...

In the viewer the six coroutines run concurrently on the same thread row, with gaps visible where each was awaiting I/O. You can click any span to see its full name, start time, and duration -- which lets you confirm that the asyncio.gather call saved time by running the fetches in parallel rather than sequentially.

Real-Life Example: Profiling a Data Pipeline

Here is a realistic scenario: a CSV processing pipeline that reads rows, cleans them, and writes a summary. We want to know which step is the bottleneck before deciding whether to optimize or parallelize.

Developer pointing at a red bottlenecked pipe in a data pipeline diagram
The pipeline looked fine until you measured it. It wasn't fine.
# data_pipeline.py
import csv
import io
import time
from viztracer import VizTracer

# Simulate an in-memory CSV with 50,000 rows
def generate_csv(rows=50_000):
    output = io.StringIO()
    writer = csv.writer(output)
    writer.writerow(["id", "value", "category"])
    for i in range(rows):
        writer.writerow([i, i * 1.5, f"cat_{i % 10}"])
    output.seek(0)
    return output

def read_rows(csv_file):
    """Step 1: Read all rows into memory."""
    reader = csv.DictReader(csv_file)
    return list(reader)

def clean_rows(rows):
    """Step 2: Parse and validate each row."""
    cleaned = []
    for row in rows:
        try:
            cleaned.append({
                "id": int(row["id"]),
                "value": float(row["value"]),
                "category": row["category"].strip(),
            })
        except (ValueError, KeyError):
            pass  # skip malformed rows
    return cleaned

def summarize(rows):
    """Step 3: Compute per-category totals."""
    totals = {}
    for row in rows:
        cat = row["category"]
        totals[cat] = totals.get(cat, 0) + row["value"]
    return totals

def run_pipeline():
    csv_file = generate_csv(50_000)
    rows = read_rows(csv_file)
    cleaned = clean_rows(rows)
    summary = summarize(cleaned)
    return summary

with VizTracer(
    output_file="pipeline_trace.json",
    max_stack_depth=10,
    ignore_frozen=True,
):
    result = run_pipeline()

# Print a sample of the summary
for category, total in sorted(result.items())[:3]:
    print(f"{category}: {total:.1f}")
print(f"Categories processed: {len(result)}")
cat_0: 37496250.0
cat_1: 37497750.0
cat_2: 37499250.0
Categories processed: 10
viztracer: Saving trace data to pipeline_trace.json ...

Open pipeline_trace.json and you will immediately see the relative widths of read_rows, clean_rows, and summarize. In practice clean_rows is the widest bar because it calls int() and float() 50,000 times in a loop. That is the function to optimize first -- perhaps by switching to NumPy for the type conversions or by filtering at read time with a streaming approach. viztracer showed you which step to target in a single trace run, saving you the time of guessing or profiling each function separately.

Frequently Asked Questions

How much does viztracer slow down my program?

viztracer uses a C extension for instrumentation, which keeps overhead low. For most programs you can expect a 2-3x slowdown compared to running without tracing. This means a 1-second script takes 2-3 seconds under viztracer. If your program is heavily recursive or calls millions of tiny functions, the overhead can be higher. You can reduce it significantly with --max_stack_depth to limit how many call levels are recorded, and min_duration to skip very short calls. For performance-critical measurement, do a baseline run without viztracer first and compare ratios rather than absolute numbers.

My result.json is several hundred megabytes. How do I handle that?

Very long-running programs or programs that make millions of calls generate large trace files. Use two strategies: first, limit scope with the context manager or decorator so you only trace the section you care about. Second, use the tracer_entries argument to set a circular buffer size -- VizTracer(tracer_entries=1_000_000) keeps only the last 1 million events and discards older ones. The vizviewer at ui.perfetto.dev handles files up to a few hundred MB without issue. For files larger than that, filter at the command line with viztracer --max_stack_depth 5 to produce a smaller trace from the start.

Does viztracer work with multiprocessing?

Yes. Pass pid_suffix=True to the VizTracer constructor and each process will write its own trace file with the PID appended to the filename (e.g., result_12345.json, result_12346.json). You can then merge multiple JSON traces into a single view using viztracer --combine result_12345.json result_12346.json -o combined.json. The combined view shows all processes on separate rows, which is useful for spotting inter-process synchronization delays.

When should I use viztracer vs cProfile?

Use cProfile when you need a quick aggregate answer: "which function is called the most?" or "where does the total CPU time go?" It is built into Python and adds minimal overhead. Use viztracer when you need to understand the sequence of execution: "why is this function slow only on the third call?" or "which code path is hitting this bottleneck?" viztracer also handles async and threading better than cProfile. A good workflow is to use cProfile for an initial survey and then viztracer to drill into the hot spot.

Can I use viztracer with Django or Flask?

Yes, but with care. You typically do not want to trace an entire web server process. Instead, wrap specific view functions or middleware calls using the VizTracer context manager inside the function body. For Django management commands, you can trace the entire command with the CLI runner: viztracer manage.py my_command --max_stack_depth 15. Keep ignore_frozen=True to exclude Django's internal machinery and focus on your application code. Because web servers are long-running, always use tracer_entries to limit buffer size and avoid unbounded memory growth.

Can I export the trace to formats other than JSON?

viztracer outputs Perfetto-compatible JSON by default, which is the most feature-rich format. You can view it with vizviewer result.json (local, no internet needed) or upload it to https://ui.perfetto.dev/ for a more polished interface. For CI pipelines or automated comparison, the JSON is easy to parse programmatically -- the traceEvents key holds a list of span objects with name, ts (timestamp in microseconds), and dur (duration) fields. There is no built-in HTML or PDF export, but you can write a simple script to pull the top-N slowest spans from the JSON and report them in any format you need.

Conclusion

viztracer makes the invisible visible. Instead of staring at aggregate numbers from cProfile and guessing which call path is slow, you get an interactive timeline that shows exactly what your program did, when it did it, and how long each step took. We covered tracing from the command line with viztracer my_script.py, focusing traces with the VizTracer context manager and @trace_and_save decorator, reducing noise with --max_stack_depth and --ignore_frozen, annotating traces with add_instant and log_var, and tracing async and multi-threaded programs without any special configuration.

The next step is to run viztracer against a real bottleneck in your own codebase. Wrap the slow function in a with VizTracer() block, open the flame graph, and look for the widest bar that is not a built-in. That is your target. From there you can decide whether to optimize the algorithm, cache the result, or move the work to a background thread -- and you can make that decision based on evidence rather than intuition.

For the full list of CLI flags, configuration options, and advanced features like remote profiling and snapshot mode, see the official viztracer documentation.