Checkpoints, Export and Inference

Why torch.load can run code, and safetensors

A .pt file is a pickle, and a pickle is a small program that runs when you open it — weights_only=True blocks that, and the safetensors format removes the possibility entirely.

On this page 6
  1. Why this matters to you
  2. The two fixes
  3. How it works
  4. A real example you have seen
  5. Remember this
  6. What to learn next

One lesson, three depths. Pick the one that fits you today — you can switch any time.

Beginner — No maths. Plain English.

Opening a downloaded model file used to be like running a downloaded program, because that is what it was.

Think of the difference between a photo and a shortcut icon. A photo is data: your phone displays it and nothing else happens. A shortcut is an instruction: opening it makes the computer do something. They can sit next to each other looking equally harmless.

Python's standard way of saving objects, called pickle, is closer to the shortcut. A pickle file does not contain a finished object. It contains instructions for building one — and instructions can say anything.

Why this matters to you

You download a model from a public hub. You load it. Nothing looks unusual, the weights are correct, and the model works.

While loading, the file could also have read your cloud credentials, or added itself to your startup programs. The file works and does the other thing. There is no visible symptom.

This is not theoretical. Model hubs scan uploads for exactly this pattern, and they find some.

The two fixes

The switch. torch.load(..., weights_only=True) allows only plain numbers to come out of the file and refuses anything that looks like an instruction. From PyTorch 2.6 it is the default.

The format. A newer file type called safetensors stores numbers and a small description of their shapes. There is nowhere in the format to put an instruction, so there is nothing to block.

How it works

model.pt (pickle):
   [ instructions to rebuild objects ]  <- can include "run this"
   [ blocks of numbers ]

model.safetensors:
   [ a header: names, shapes, types, byte offsets ]   <- data only
   [ blocks of numbers ]
                                          no place to put code

A real example you have seen

Email attachments. A .jpg is safe to open; a .exe is not, even when it is named holiday_photo.exe. Your mail provider blocks the second kind. weights_only=True is the same policy, applied to model files.

Remember this

  • A .pt file is a program, not only data, unless you restrict it.
  • Pass weights_only=True, and prefer files that were saved that way.
  • safetensors removes the risk by having nowhere to put code.

What to learn next

Developer — Code and libraries.

Setup

bash
pip install torch safetensors

Everything runs on CPU. The demonstration below is deliberately harmless — it prints a line where a real attack would do something you would not enjoy. The mechanism is identical.

Watching a checkpoint execute code

pickle_risk.py
import re
import torch

class Sneaky:
    """A pickle stores instructions, not data. This one calls print; a real
    attack calls something worse. The mechanism is identical."""
    def __reduce__(self):
        return (print, ("*** code from the checkpoint file just ran ***",))

torch.save({"weights": torch.zeros(2), "note": Sneaky()}, "sneaky.pt")

print("loading with weights_only=False:")
torch.load("sneaky.pt", weights_only=False)

print("\nloading with weights_only=True:")
try:
    torch.load("sneaky.pt", weights_only=True)
except Exception as e:
    clean = re.sub(r"\x1b\[[0-9;]*m", "", str(e))      # strip terminal colours
    print(type(e).__name__)
    print(clean.splitlines()[0])
    print([l for l in clean.splitlines() if "Unsupported global" in l][0].strip())
Output
loading with weights_only=False:
*** code from the checkpoint file just ran ***

loading with weights_only=True:
UnpicklingError
Weights only load failed. This file can still be loaded, to do so you have two options, do those steps only if you trust the source of the checkpoint.
WeightsUnpickler error: Unsupported global: GLOBAL builtins.print was not an allowed global by default. Please use `torch.serialization.add_safe_globals([print])` to allowlist this global if you trust this class/function.

Notice what the first load did not do. It did not warn, it did not slow down, and it produced a perfectly valid dictionary with the right weights in it. The code ran as a side effect of opening the file.

__reduce__ is a standard, documented part of Python's pickle protocol. It returns a callable and its arguments, and the unpickler calls it. Nothing here is a bug or an exploit of a flaw — it is the format working as designed.

The weights_only unpickler has an allowlist of types it will construct: tensors, storages, OrderedDict, and primitives. Anything else raises, naming the exact global it refused. That error message is what a blocked attack looks like.

Reading a file before you trust it

peek_pickle.py
import pickletools
import zipfile

with zipfile.ZipFile("sneaky.pt") as z:                 # a .pt file is a zip
    members = z.namelist()
    name = [n for n in members if n.endswith("data.pkl")][0]
    ops = [op.name for op, _, _ in pickletools.genops(z.read(name))]

print("archive members:", len(members))
print("GLOBAL/STACK_GLOBAL opcodes:", ops.count("GLOBAL") + ops.count("STACK_GLOBAL"))
print("REDUCE opcodes:", ops.count("REDUCE"))
Output
archive members: 5
GLOBAL/STACK_GLOBAL opcodes: 4
REDUCE opcodes: 3

GLOBAL opcodes name a function to import; REDUCE calls one. Their presence is not proof of anything — normal checkpoints contain several, since rebuilding a tensor is itself a function call. But the names they import are informative, and a file importing os or subprocess deserves a hard look. This is roughly what a model hub's scanner does at scale.

safetensors: the format with nowhere to hide

use_safetensors.py
import torch
import torch.nn as nn
from safetensors.torch import save_file, load_file, safe_open

torch.manual_seed(0)
model = nn.Sequential(nn.Linear(4, 8), nn.ReLU(), nn.Linear(8, 3))
save_file(model.state_dict(), "model.safetensors")

# read the header without loading a single weight
with safe_open("model.safetensors", framework="pt") as f:
    print("keys:", f.keys())
    print("shape of 0.weight:", f.get_slice("0.weight").get_shape())
    print("one row only:", f.get_slice("0.weight")[0:1].shape)

loaded = load_file("model.safetensors")
fresh = nn.Sequential(nn.Linear(4, 8), nn.ReLU(), nn.Linear(8, 3))
print("load report:", fresh.load_state_dict(loaded))
Output
keys: ['0.bias', '0.weight', '2.bias', '2.weight']
shape of 0.weight: [8, 4]
one row only: torch.Size([1, 4])
load report: <All keys matched successfully>

Three properties fall out of the format.

No code. The file is a JSON header plus raw bytes. There is no opcode, no callable, nothing to execute.

Lazy. safe_open read the shapes without loading the weights, and get_slice(...)[0:1] pulled one row off the disk. That is how a sharded 70-billion-parameter model gets loaded piece by piece without needing the whole thing in RAM.

Fast. The tensors are contiguous and aligned, so loading can memory-map rather than deserialise. On large models this is the difference between minutes and seconds.

What it does not do: safetensors stores tensors and a small string-to-string metadata map, and nothing else. Your optimizer state, epoch counter and config dictionary do not fit. Production setups commonly use safetensors for the weights people download and an ordinary .pt for the training state nobody else opens.

A policy that is short enough to follow

python
# your own checkpoints, written by your own job
torch.save(state, "run.pt")
state = torch.load("run.pt", weights_only=True, map_location="cpu")

# anything downloaded
from safetensors.torch import load_file
model.load_state_dict(load_file("downloaded.safetensors"))

If a downloaded model exists only as .bin or .pt, and weights_only=True refuses it, the right response is to ask why the file needs to construct a custom class — not to flip the flag off. torch.serialization.add_safe_globals([...]) lets you allow specific known classes without opening the door to everything.

Common mistakes

Passing weights_only=False to make an error go away. That error is the control working. Find out what the file wants to construct first.

Believing a scanner clears a file. Scanners catch known patterns. They are a filter, not a proof.

Trusting a file because the weights are correct. Both things happen. Correct weights are not evidence of an absent payload.

Assuming safetensors is slower because it is safer. It is generally faster to load, because there is less work to do.

Running downloaded model code because the weights were safe. A safetensors file plus a modeling_custom.py that you import is back to running someone else's code. The file format protects the weights, not your import statements — the same reasoning as prompt injection: the safe channel does not make the surrounding system safe.

Try it yourself

Change Sneaky.__reduce__ to return (len, ("hello",)) and print what torch.load(..., weights_only=False) gives back for the "note" key. Then run pickletools.dis on the pickle and find the REDUCE opcode that did it.

What to learn next

Researcher — Mathematics and papers.

The pickle protocol as an execution format

Pickle is a stack-based virtual machine. GLOBAL/STACK_GLOBAL pushes an object located by (module, name) — performing an import as a side effect — and REDUCE pops a callable and an argument tuple and calls it. object.__reduce__ is the documented hook that lets a class specify that pair, so arbitrary-callable invocation is a feature of the format rather than a defect in an implementation. No sandbox is possible without restricting the allowed globals, which is precisely the design of PyTorch's _weights_only_unpickler: a reimplementation of the unpickler that supports a fixed opcode subset and an allowlist of constructible types, extensible through torch.serialization.add_safe_globals.

weights_only=True became the default in PyTorch 2.6, following a deprecation cycle. The residual risk is not zero: an allowlisted class with a dangerous __setstate__, or a torch.load on a file whose shapes trigger an enormous allocation, remain live concerns. Restricting globals removes arbitrary code execution, not all denial-of-service.

The safetensors layout

The file is: an 8-byte little-endian unsigned integer $N$, then $N$ bytes of UTF-8 JSON header, then the tensor data region. The header maps each tensor name to {"dtype", "shape", "data_offsets": [begin, end]}, with offsets relative to the start of the data region, plus an optional __metadata__ string-to-string map. There is no type descriptor that names a Python class and no opcode stream, so the parse is total: any byte sequence either matches the grammar or is rejected.

Two consequences follow. Loading can mmap the data region and construct tensors as views over it, which is why load time approaches page-cache speed and why memory use during load approaches the model size rather than twice it. And validation is cheap and complete: offsets can be checked for overlap and for staying inside the file before any allocation happens, which the reference implementation does.

The format cannot express shared storage between tensors. Two entries pointing at overlapping bytes are rejected, so a model with tied embeddings must either duplicate the data or record the tie in __metadata__ and rebuild it after loading — a real friction point when converting existing checkpoints.

Threat model in practice

The realistic attack path is supply chain: a repository whose weights are correct and whose loader has a payload, or a typosquatted repository name. Published incidents on public model hubs have used exactly the __reduce__ mechanism demonstrated above. Defences layer rather than substitute: prefer safetensors, pin repository revisions by commit hash rather than branch name, verify checksums, and run untrusted loads in a container without credentials mounted. The last point matters most, because a model that is safe to load can still be trained to behave badly — weight-space backdoors are invisible to any file-format check, since they are the weights.

References

  • Python documentation, pickle — the security note, and the __reduce__ protocol.
  • PyTorch documentation, torch.load — weights_only semantics, add_safe_globals, and the 2.6 default change.
  • Hugging Face, safetensors format specification and security audit — the header grammar and the audited parse.
  • Gu et al. (2019), BadNets: Evaluating Backdooring Attacks on Deep Neural Networks — why a format guarantee is not a model guarantee.

What to learn next