Skip to Content
Start Free

Understanding PyTorch Autograd by Writing a Custom Training Loop

Understanding PyTorch Autograd by Writing a Custom Training Loop
PyTorch Autograd

You have almost certainly typed loss.backward() without knowing what it does. Most people do, for a long time. It sits there in the middle of the training loop looking like an instruction to a black box, and as long as the model trains, there is no reason to ask questions. Then one day you get an error saying you tried to go backward through the graph a second time, and suddenly the black box matters a great deal. This tutorial opens it up. You will watch PyTorch build a record of your calculations as they happen, walk that record backwards, and then throw it away. By the end, the rules you have been following on faith will simply look like consequences of a design you understand.

Before you begin

This is part three of a five part series on PyTorch foundations. Parts one and two both called loss.backward() without explaining it. This part opens that line up and shows you what happens inside.

Keep a Python prompt open while you read. Every snippet here is short enough to paste and run in a few seconds, and autograd makes far more sense when you watch it than when you read about it.

Highlights

  • Automatic differentiation is neither symbolic algebra nor numerical estimation, since it records operations as they run and then replays them backwards using exact reversal rules.
  • The recording is destroyed the moment it is replayed, which is why calling backward twice on a single forward pass raises an error rather than simply repeating the work.
  • Gradients accumulate on parameters until something clears them, which makes zero_grad a structural requirement rather than a ritual you copy from other people's code.
  • Only tensors you created directly receive gradients, so intermediate values report None and PyTorch emits a warning when you try to read them.
  • Building the graph while the code runs is what allows ordinary Python conditionals, loops, print statements, and debuggers to work inside a model.
  • Turning tracking off has two distinct forms, because no_grad prevents history from being recorded at all while detach cuts one tensor free from history that already exists.
  • Accumulating loss tensors instead of plain numbers keeps every batch's graph alive in memory, which is the usual cause of training that consumes memory until the process is killed.

What Automatic Differentiation Solves

Training a model means answering one question, repeatedly, for every adjustable number it contains: if I nudge this weight slightly, does the error go up or down, and by how much? The answer is a derivative, and there are three ways to get one.

You could work it out symbolically with algebra, which is exact but becomes unmanageable once the expression is a hundred layers deep. You could estimate it numerically by trying a slightly different value and seeing what changes, which is simple but slow and imprecise. Or you could record each operation as it happens, then replay the recording backwards applying a known reversal rule at each step.

That third approach is automatic differentiation, and it is what autograd does. It is exact like the symbolic method and fast like the numerical one, because every primitive operation in PyTorch ships with its derivative already written by hand.

The short version

Autograd records every operation performed on a tracked tensor, then replays that recording backwards to work out how each input affected the final result. It is exact, not an approximation.

Why the Graph Is Built While Your Code Runs

The most consequential design decision in PyTorch is that this recording is made during execution rather than declared in advance. Frameworks that require you to define the full structure up front can optimise it more aggressively, but they take your Python away from you in the process.

ApproachWhat you gainWhat it costs
Graph built while running, which is PyTorch's defaultOrdinary Python control flow, print statements mid model, and standard debuggers all workThe graph is rebuilt every pass, so there is some repeated overhead
Graph declared ahead of timeThe framework can optimise the whole structure before any data flowsConditionals and variable length loops become framework specific constructs
Traced then compiled, which torch.compile doesYou develop against the flexible version and switch on the fast one when the code settlesTracing can miss branches your test input never took

This is the main reason PyTorch took over research, and why most new models are released as PyTorch code first. You can put an if statement inside a model and it simply works.

The Three Rules That Explain Every Gradient Error

Nearly every confusing autograd error you will meet follows from three facts about how the recording is kept. Everything demonstrated below is a consequence of one of them.

The recording is discarded once it has been replayed. Backward frees the intermediate values it needed, because holding them costs memory and almost nobody wants them twice. This is why calling backward a second time fails.

Results accumulate rather than replace. Each backward pass adds its findings to whatever is already stored on a parameter. This is why something has to clear them between steps.

Only tensors you created directly receive results. Model parameters get gradients. Intermediate values calculated along the way do not, because no optimizer would ever update them.

Hold those three and the error messages stop being mysterious. The rest of this article shows each one happening in printed output.

Before you start

  1. PyTorch 2.x installed. All output below is from version 2.13.0 on CPU.
  2. You have written or read a training loop. Part one of this series builds one from scratch if you need it.
  3. A Python REPL open, because every snippet here is short enough to paste and run immediately.
  4. No calculus required. You need to know that a derivative measures rate of change, and nothing beyond that.

Run it in your browser

You do not have to install anything on your own machine. The JupyterLab playground gives you a browser based notebook environment running on a Linux host, so you can open a terminal inside it, install PyTorch with a single pip command, and work through every step of this tutorial in notebook cells. Sessions last one hour and extend by 15 minutes, which is comfortably longer than this tutorial needs. Install with %pip install --upgrade pip first, then %pip install torch numpy --index-url https://download.pytorch.org/whl/cpu. The percent sign matters because a notebook cell runs Python rather than shell, the playground image ships a pip old enough to fail without the upgrade, and numpy stops torch printing a startup warning even though nothing here calls it.

Step 1: Watch a Tensor Start Tracking

Set requires_grad=True and PyTorch begins recording.

import torch

x = torch.tensor([2.0], requires_grad=True)
y = x ** 3

print("x        :", x)
print("y = x**3 :", y)
print("y.grad_fn:", y.grad_fn)

y.backward()
print("dy/dx at x=2:", x.grad.item())
x        : tensor([2.], requires_grad=True)
y = x**3 : tensor([8.], grad_fn=<PowBackward0>)
y.grad_fn: <PowBackward0 object at 0x7fa40d51b280>
dy/dx at x=2: 12.0

Did you know

The hexadecimal figure in that output is a memory address, and it will differ every time you run this, even on the same machine. It is the only thing in this series that does not reproduce, so ignore it. What matters is the class name, PowBackward0, because that is the object holding the rule for reversing a power operation.

What this code does

requires_grad=True

Switches on recording for this tensor. From here on, every operation involving it gets logged so it can be replayed backwards.

y.grad_fn

The record itself. PowBackward0 is an object that knows how to reverse a power operation. Every tensor produced from a tracked tensor carries one of these.

y.backward()

Walks that record in reverse, applying each reversal rule in turn, and deposits the result into x.grad.

Why the answer is exactly 12.0

The derivative of x cubed is 3 times x squared, which at x equals 2 gives 12. Autograd is not estimating by sampling nearby points, it is applying exact rules, which is why the number is exact.

Three things happened worth naming.

y carries a grad_fn. The tensor holds the value 8.0 and also a reference to PowBackward0, the object that knows how to reverse a power operation. Every tensor produced from a tracked tensor gets one of these.

backward() walked the graph in reverse. It started at y, found PowBackward0, applied the derivative rule for powers, and deposited the result on x.

The answer is exact. The derivative of x cubed is 3 times x squared, which at x equals 2 gives 12. Autograd returned exactly 12.0, not an approximation from sampling nearby points.

Did you know

Autograd is not numerical differentiation and it is not symbolic algebra. It applies the chain rule to a recorded sequence of primitive operations, each of which has a hand written derivative built into PyTorch. That is why it is both exact and fast enough to run on every training step.

Step 2: See the Graph Structure

The graph is a chain of backward functions, and you can inspect it directly.

p = torch.tensor([4.0], requires_grad=True)
q = p * 2
r = q + 1

print("r.grad_fn               :", r.grad_fn)
print("r.grad_fn.next_functions:", r.grad_fn.next_functions)
r.grad_fn               : <AddBackward0 object at 0x7fa3eeaeebc0>
r.grad_fn.next_functions: ((<MulBackward0 object at 0x7fa3eeaeec80>, 0), (None, 0))

What this code does

Read the chain backwards

The last operation was an addition, so r holds AddBackward0. That node points back at MulBackward0, the multiplication that produced q.

The None entry

That is the constant 1. It needs no gradient because it is a fixed number rather than something being learned.

Why this matters

In a real network this chain runs through every layer and every operation between your input and your loss. Calling .backward() on the loss traverses the entire thing in one pass.

Read that backwards, which is how autograd reads it. The final operation was an addition, so r holds AddBackward0. That node points to MulBackward0, the multiplication that produced q. The None is the constant 1, which needs no gradient because it is not a tracked tensor.

In a real network this chain runs through every layer, every activation, and every arithmetic operation between the input and the loss. Calling .backward() on the loss traverses the whole thing.

Step 3: Prove That Gradients Accumulate

This is the behaviour behind the most repeated rule in PyTorch, and it is easier to accept once you have watched it.

a = torch.tensor([1.0], requires_grad=True)
for step in range(1, 4):
    out = (a * 3).sum()
    out.backward()
    print(f"after backward #{step}: a.grad = {a.grad.item()}")
after backward #1: a.grad = 3.0
after backward #2: a.grad = 6.0
after backward #3: a.grad = 9.0

The correct gradient is 3.0 every time, because the derivative of 3a with respect to a is 3 regardless of how many times you ask. What changes is the stored value, because each call adds to what is already there.

Now the same loop with a reset:

a2 = torch.tensor([1.0], requires_grad=True)
for step in range(1, 4):
    if a2.grad is not None:
        a2.grad.zero_()
    out = (a2 * 3).sum()
    out.backward()
    print(f"after backward #{step}: a2.grad = {a2.grad.item()}")
after backward #1: a2.grad = 3.0
after backward #2: a2.grad = 3.0
after backward #3: a2.grad = 3.0

What this code does

Why the first loop climbs

The correct gradient is 3.0 every time, since the derivative of 3a with respect to a is always 3. What changes is the stored total, because each call adds to what was already there.

a2.grad.zero_()

Resets the stored value to zero before the next backward pass, which is why the second loop prints 3.0 three times.

What optimizer.zero_grad() really is

Exactly this operation, applied across every parameter in your model at once instead of one line per tensor.

Why PyTorch works this way

Accumulation is what lets you split one large batch into several small ones when memory is tight, then update once. PyTorch makes the useful behaviour the default and asks you to opt out.

That is what optimizer.zero_grad() does across every parameter in your model at once.

This design is deliberate rather than an oversight. Accumulation is what makes gradient accumulation across several small batches possible when a single large batch will not fit in memory, a routine technique when training large models on limited hardware. PyTorch's position is that you should opt out of accumulation explicitly rather than have it opted out for you.

Step 4: Leaf Tensors and Why .grad Is Often None

Not every tensor receives a gradient, and the distinction confuses almost everyone at first.

leaf = torch.tensor([3.0], requires_grad=True)
mid = leaf * 2
final = mid.sum()
final.backward()

print("leaf.is_leaf:", leaf.is_leaf, "| leaf.grad:", leaf.grad.item())
print("mid.is_leaf :", mid.is_leaf,  "| mid.grad :", mid.grad)
leaf.is_leaf: True | leaf.grad: 2.0
mid.is_leaf : False | mid.grad : None

What this code does

A leaf tensor

One you created directly, rather than one that came out of an operation. Model parameters are leaves. Activations and intermediate results are not.

Why mid.grad is None

Autograd only stores gradients on leaves, because those are the only tensors an optimizer will ever update. Keeping them for every intermediate value would burn memory on data nobody reads.

.retain_grad()

The escape hatch. Call it on an intermediate tensor before the backward pass if you genuinely need its gradient, usually for debugging or for techniques like saliency mapping.

PyTorch also prints a warning on that second line:

UserWarning: The .grad attribute of a Tensor that is not a leaf Tensor is being
accessed. Its .grad attribute won't be populated during autograd.backward(). If you
indeed want the .grad field to be populated for a non-leaf Tensor, use .retain_grad()
on the non-leaf Tensor.

A leaf tensor is one you created directly rather than one that came out of an operation. Model parameters are leaves. Activations and intermediate results are not.

Autograd only stores gradients on leaves because those are the only tensors an optimizer updates. Keeping gradients for every intermediate value would consume memory for data nobody uses. If you genuinely need an intermediate gradient, usually for debugging or for techniques like saliency mapping, call .retain_grad() on that tensor before the backward pass.

Step 5: Two Ways to Stop Tracking

torch.no_grad() and .detach() both disable gradient tracking, and they are not interchangeable.

m = torch.tensor([5.0], requires_grad=True)

n = m * 2
print("n.requires_grad          :", n.requires_grad)
print("n.detach().requires_grad :", n.detach().requires_grad)

with torch.no_grad():
    o = m * 2
print("inside no_grad()         :", o.requires_grad)
n.requires_grad          : True
n.detach().requires_grad : False
inside no_grad()         : False

What this code does

n.requires_grad is True

n came from a tracked tensor, so it is part of the graph and inherits tracking automatically.

.detach()

Returns a copy that shares the same numbers but has no link to the graph behind it. Use it when you need one value out of a live model, such as for logging.

torch.no_grad()

Switches off recording for everything inside the block. Use it for validation, inference, and manual parameter updates, where you have no intention of differentiating anything.

The practical difference

no_grad() prevents history from being created at all, while .detach() cuts one tensor free from history that already exists.

ToolWhat it doesUse it when
torch.no_grad()Stops the graph being recorded for every operation inside the blockRunning validation, inference, or manual parameter updates
.detach()Returns a copy of one tensor with no link to the graph behind itLogging a value, converting to NumPy, stopping gradient flow mid model
.item()Extracts a single number as a plain Python floatAccumulating a running loss total across batches

The .item() row matters more than it looks. Writing running_loss += loss instead of running_loss += loss.item() keeps every batch's entire computation graph alive in memory, and by the end of an epoch the process has consumed several gigabytes for a number you only wanted to print.

Step 6: A Complete Training Loop Annotated by Graph State

Here is the standard loop with a note on what autograd is doing at each line.

from torch import nn

model = nn.Linear(4, 2)
loss_fn = nn.CrossEntropyLoss()
optimizer = torch.optim.SGD(model.parameters(), lr=0.1)
x, y = torch.randn(8, 4), torch.randint(0, 2, (8,))

for epoch in range(3):
    pred = model(x)              # graph is built here, node by node
    loss = loss_fn(pred, y)      # graph now runs from x through to loss

    optimizer.zero_grad()        # every parameter .grad is set to zero
    loss.backward()              # graph traversed in reverse, .grad populated, graph freed
    optimizer.step()             # parameters updated using .grad, no graph involved

    print(f"epoch {epoch} | loss {loss.item():.4f}")

The ordering follows from the mechanics. Gradients must be cleared before backward() because backward() adds to them. step() must come after backward() because it reads the values backward() wrote. And the forward pass must be inside the loop because the graph is destroyed on every backward call.

Break It On Purpose

Two experiments, each taking under a minute, that make the rules stick.

Break it on purpose, experiment one

Call .backward() twice on a single forward pass.

x = torch.tensor([2.0], requires_grad=True)
y = x * 3
y.backward()
y.backward()   # RuntimeError
RuntimeError: Trying to backward through the graph a second time
(or directly access saved tensors after they have already been freed).

Why it happens: PyTorch frees the intermediate values as soon as the first backward pass finishes, because holding them costs memory and almost nobody needs them twice. The error suggests retain_graph=True, but that is rarely the right answer. In a training loop the correct fix is to move the forward pass inside the loop so a fresh graph is built each iteration.

Break it on purpose, experiment two

Update a parameter without wrapping it in torch.no_grad().

w = torch.zeros(1, requires_grad=True)
w -= 0.1   # RuntimeError
RuntimeError: a leaf Variable that requires grad is being used
in an in-place operation.

Why it happens: modifying a leaf tensor in place would corrupt the history autograd needs, so PyTorch blocks it. Wrapping the update in torch.no_grad() tells autograd that this particular change is bookkeeping rather than part of the model, which is exactly what optimizer.step() does internally.

Error Decoder

Every message below was produced by running the broken code on PyTorch 2.13.0.

What PyTorch printsWhat it means and how to fix it
RuntimeError: Trying to backward through the graph a second timeThe graph was freed by the first backward call. Move the forward pass inside your loop rather than reaching for retain_graph=True.
RuntimeError: a leaf Variable that requires grad is being used in an in-place operationYou modified a parameter outside torch.no_grad(). Wrap the update, or let an optimizer handle it.
RuntimeError: Only Tensors of floating point and complex dtype can require gradientsGradients are undefined for integers. Create the tensor as a float, for example torch.tensor([1.0]).
RuntimeError: grad can be implicitly created only for scalar outputsYou called .backward() on a multi element tensor. Reduce to one number first with .mean() or .sum().
RuntimeError: Can't call numpy() on Tensor that requires gradUse tensor.detach().numpy(). The tensor is still attached to a live graph.
UserWarning: The .grad attribute of a Tensor that is not a leaf Tensor is being accessedYou are reading .grad on an intermediate value, which is always None. Read it on the parameter instead, or call .retain_grad() first.
Memory grows steadily across an epoch until the process is killedYou accumulated loss tensors rather than floats. Use running_loss += loss.item().

Why the Dynamic Graph Design Matters

PyTorch rebuilds the graph on every forward pass. That sounds wasteful, and it has a real cost, but it buys something valuable: your model is ordinary Python.

You can put an if statement in forward and take different paths for different inputs. You can loop a variable number of times based on sequence length. You can insert a print in the middle of a forward pass and see actual tensor values, or set a breakpoint and step through with a standard Python debugger. None of that requires special framework support, because there is no separate compilation step deciding the structure in advance.

This is the main reason PyTorch became dominant in research, and why most new models are released as PyTorch code first. When you need the performance of a static graph, torch.compile() traces your model and optimises it, so you develop with the flexible version and switch on the fast one when the code is settled.

Course

PyTorch

Dedicates a full module to autograd, moving from the theory of automatic differentiation into using it inside real training scenarios, which is the natural next step once the printed output above makes sense.

AutogradComputation graphsTraining
Explore the course β†’

Conclusion

Autograd stops feeling like magic once you accept three facts. The graph is built as your code runs and destroyed as soon as you traverse it backwards. Gradients accumulate on leaf tensors until something clears them. Tracking can be switched off for a whole block with torch.no_grad() or for one tensor with .detach().

Every gradient rule you have been following falls out of those facts. Clear before backward because backward adds. Rebuild the forward pass each iteration because the graph is gone. Wrap evaluation in no_grad() because you are not going to differentiate it and building the graph wastes memory. Call .item() when logging because otherwise you keep the whole graph alive for a single float.

Spend ten minutes in a REPL printing grad_fn, is_leaf, and .grad on tensors as you build small expressions. That direct experimentation does more than any amount of reading, because the objects are inspectable and the behaviour is completely deterministic.

FAQS

Q1: What is the difference between torch.no_grad and detach?

torch.no_grad() is a context manager that switches off graph construction for everything inside its block, so no operation performed within it records history. .detach() operates on one existing tensor and returns a copy that shares the same underlying data but has no connection to the graph that produced it. Use no_grad() when an entire region of code should not be differentiated, which covers validation loops, inference, and manual parameter updates. Use .detach() when you need to extract one value from a live graph, typically for logging, converting to NumPy, or deliberately blocking gradient flow at a specific point in a model. A useful way to hold the distinction is that no_grad() prevents history from being created while .detach() cuts an existing tensor free from history that already exists.

Q2: Why does PyTorch accumulate gradients instead of replacing them?

Because accumulation enables techniques that would otherwise be impossible, most importantly gradient accumulation across multiple small batches. If a batch size of 64 exceeds your GPU memory, you can run four batches of 16, let the gradients sum, and call optimizer.step() once, producing mathematically the same update as a single batch of 64. Accumulation is also required for models where one parameter contributes to the loss through several paths, since each path's contribution must be added rather than overwritten. PyTorch's designers chose to make the useful behaviour the default and require an explicit opt out, which is why zero_grad() exists as a call you make rather than something the framework does silently. Once you have seen the gradient climb from 3.0 to 6.0 to 9.0 the rule becomes obvious rather than arbitrary.

Q3: Do I need to understand autograd to use PyTorch, or can I just call backward?

You can build working models by following the standard loop without understanding the internals, and many people do for a long time. The understanding pays off when something goes wrong, because gradient related bugs are rarely self explanatory. A model whose loss will not decrease, a training process that consumes memory until the machine kills it, or an error about backward being called twice all become straightforward once you know the graph is built during the forward pass and freed by the backward pass. The investment is genuinely small, roughly the length of this article plus some experimentation in a REPL, and it converts an entire category of confusing failures into obvious ones. It also makes the framework's design choices feel reasonable rather than arbitrary.

Q4: What does requires_grad actually do, and should I set it on my data?

Setting requires_grad=True tells PyTorch to record every operation involving that tensor so gradients can flow back to it. You set it on things you want to optimise, which in practice means model parameters, and nn.Module does that automatically for every layer you define. You do not set it on input data or labels, because you are not trying to change your dataset, and doing so wastes memory building graph nodes nobody will use. The main exception is adversarial examples and input attribution methods, where you deliberately compute gradients with respect to the input in order to see which pixels or features influenced a prediction. For ordinary training you should almost never need to set the flag by hand.

Q5: How does PyTorch autograd compare to how TensorFlow handles gradients?

Both compute gradients automatically and the concepts map closely onto each other, but the timing differs. PyTorch builds the graph while your code executes, so the structure reflects whatever path your Python actually took on this specific input. TensorFlow originally required you to define a static graph in advance and then feed data through it, which made optimisation easier but debugging much harder, and TensorFlow 2 adopted eager execution by default in response. The practical result today is that both frameworks feel similar to use, with tf.GradientTape playing a role comparable to PyTorch's automatic recording. PyTorch's dynamic approach still tends to feel more natural for models with data dependent control flow, such as recursive structures or variable length sequences.

Q6: My model trains but the loss barely moves. Is autograd the problem?

Autograd itself is rarely the culprit, since it computes exact derivatives, but the way it is used often is. Check three things in order. First, confirm optimizer.zero_grad() is called each iteration, because accumulated gradients produce erratic updates that can stall progress. Second, print the gradient magnitude on a few parameters with param.grad.abs().mean(), since values at or near zero mean gradients are not reaching those layers while very large values suggest the learning rate is too high. Third, verify that your loss actually depends on your model output, because a .detach() left in the wrong place silently disconnects the graph and produces a loss that no parameter can influence. Beyond gradients, verify your prediction and target shapes match, since a silent broadcasting mismatch produces a loss that looks reasonable while measuring the wrong thing, a failure explored in detail in part two of this series.

Pramodh Kumar M Pramodh Kumar M

Subscribe to Newsletter

Join me on this exciting journey as we explore the boundless world of web design together.