Tracing vs. Scripting in TorchScript: Picking the Right Path to Production
TorchScript offers tracing and scripting for exporting PyTorch models — but tracing silently bakes in whichever branch your example input takes. Here's how to choose, export, and verify.
22 May 2026, 15:54 UTC

Your PyTorch model works beautifully in a notebook, but now it needs to run in a C++ service where there's no Python interpreter. Or you want to shave the Python overhead off a hot inference path. TorchScript solves both problems by converting your torch.nn.Module into a serializable, optimizable intermediate representation — but it offers two very different ways to get there, and choosing the wrong one can silently produce a model that's wrong for some inputs.
The short version: use scripting (torch.jit.script) when your model has data-dependent control flow, and tracing (torch.jit.trace) when it doesn't and you want the least friction. The rest of this post explains why, with a concrete example and a verification step you should not skip.
What each conversion actually does
Tracing runs your model once with example inputs and records every tensor operation that executes. The result is a graph of exactly what happened during that single pass. It's forgiving — almost any Python code that runs will trace — but it is blind to the paths it didn't take.
Scripting compiles your forward method's source directly. It understands a statically analyzable subset of Python: if/else, loops over fixed ranges, tuples, and simple type annotations. Because it compiles the control flow itself rather than recording one execution, branches survive into the exported model.
The catch with scripting is that arbitrary Python — recursion, arbitrary data structures, many built-ins, printing intermediate values — will fail to compile, and you'll need to refactor or fall back to tracing.
The trap: data-dependent control flow
Consider a module that behaves differently depending on a tensor's value:
import torch
class Gated(torch.nn.Module):
def forward(self, x: torch.Tensor) -> torch.Tensor:
if x.sum() > 0:
return x * 2
return x * -1
model = Gated()
example = torch.ones(4)
traced = torch.jit.trace(model, example)
scripted = torch.jit.script(model)
# This input takes the OTHER branch:
neg = -torch.ones(4)
print(scripted(neg)) # tensor([1., 1., 1., 1.]) -- correct
print(traced(neg)) # tensor([-2., -2., -2., -2.]) -- WRONG
The traced model baked in the x * 2 branch because that's what executed for the all-ones example input. It will silently produce wrong outputs for any input that should take the other branch — no error, no warning at inference time. Run this in any Python environment with PyTorch installed (no special permissions needed). The exact printed values are illustrative; verify on your own version.
A supported export workflow
For a realistic deployment, scripting a standard model looks like this (run in your training/export environment, Python 3.x with PyTorch 2.x assumed):
import torch
import torchvision.models as models
model = models.resnet18(weights=None)
model.eval()
scripted = torch.jit.script(model)
# Sanity check BEFORE saving:
x = torch.randn(1, 3, 224, 224)
with torch.no_grad():
eager_out = model(x)
script_out = scripted(x)
assert (eager_out - script_out).abs().max() < 1e-5, "outputs diverge"
torch.jit.save(scripted, "resnet18_scripted.pt")
The assertion is the important part: always compare eager and scripted outputs on the same input batch before shipping the artifact. On the C++ side, load it with libtorch:
#include
auto module = torch::jit::load("resnet18_scripted.pt");
module.eval();
auto input = torch::randn({1, 3, 224, 224});
auto output = module.forward({input}).toTensor();
Then compare the C++ output tensor against the Python eager output with the same tolerance check to close the loop. If you want to confirm the compiler produced the graph you expect, inspect scripted.inlined_graph or call torch.jit.last_executed_optimized_graph() after a forward pass — unexpected aten:: fallbacks can indicate constructs TorchScript couldn't optimize.
Trade-offs and limitations
- Tracing is fragile under control flow, as shown above. It also specializes on the example input's shapes in some cases; test with varied shapes if your model is shape-polymorphic.
- Scripting rejects unsupported Python. Refactoring a model full of dynamic Python into the TorchScript subset can be real work, and some models simply can't be scripted without redesign.
- Performance gains are modest, not magical. TorchScript removes Python interpreter overhead and fuses pointwise operations; benchmarks on GPU workloads have shown roughly 1.3× speedups at moderate batch sizes, with comparable memory use. Treat any specific number as workload-dependent and measure your own.
- Version skew matters. A
.ptfile saved from one PyTorch version may not load in a much older libtorch. Pin versions between export and deployment, and verify loading in CI.
A decision rule you can apply today
Start with torch.jit.script. If it compiles, you get correct control flow for free. If compilation fails and refactoring isn't worth it, trace — but then write a test that feeds the traced model inputs covering every branch of your original forward, and assert outputs match eager mode within a tolerance like 1e-5. That test is your safety net against the silent-wrong-branch failure mode, and it's cheap insurance compared to debugging a production model that returns plausible-looking garbage.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.