What's Inside a .safetensors File, Byte by Byte

Safetensors is one of the least interesting file formats in machine learning, and that is the entire achievement. It does one thing — store named arrays of numbers — with no extensibility, no cleverness and no way to express anything that is not a tensor. Every property people value in it follows directly from that refusal.

The whole format

[8 bytes ] header length N, little-endian u64 [N bytes ] UTF-8 JSON header [remainder] raw tensor bytes, one contiguous buffer

That is the complete specification. The JSON names each tensor and gives its dtype, shape and byte range within the buffer.

A real file, byte by byte

A tiny model with two tensors. Reading from offset zero:

// Bytes 0-7 — header length, little-endian B4 00 00 00 00 00 00 00180 // Bytes 8-187 — the JSON header { "embed.weight": { "dtype": "F16", "shape": [1000, 64], "data_offsets": [0, 128000] }, "out.bias": { "dtype": "F32", "shape": [1000], "data_offsets": [128000, 132000] } } // Bytes 188 onward — the tensor buffer // embed.weight = buffer[0 .. 128000] // out.bias = buffer[128000 .. 132000]

Check the arithmetic, because it is the whole design: 1000 × 64 elements at 2 bytes each (F16) is 128,000 bytes, which is exactly the range given. 1000 elements at 4 bytes each (F32) is 4,000, which is exactly the second range. Nothing is compressed, nothing is encoded, nothing is inferred.

💡 Offsets are relative to the buffer, not the file

A tensor at data_offsets: [0, 128000] does not start at file byte 0 — it starts at byte 0 of the buffer, which begins after the 8-byte prefix and the header.

The absolute position is 8 + header_length + offset. Getting this wrong is the classic error when writing a reader by hand, and it produces tensors that are shifted rather than obviously broken — which is much harder to diagnose.

The dtype table

CodeTypeBytesCommon use
F64double8Rare
F32float4Older weights, biases
F16half2Inference weights
BF16bfloat162Modern default
F8_E4M3fp81Aggressive quantisation
I64 … I8signed int8 … 1Indices, tokens
U8unsigned byte1Packed quantised data
BOOLboolean1Masks

Note what is missing: there is no complex type, no string type, no nested or ragged type. A tensor is a rectangular block of one numeric type, which is why the offset arithmetic always works out exactly.

Why loading is nearly instant

This is the property that makes the format worth adopting even setting security aside.

// Pickle: read bytes → parse the stream → construct objects // → allocate arrays → copy data into them // Safetensors: mmap the file → hand the OS-mapped region // to the tensor as its backing store

Because the bytes are already in the layout a tensor expects, there is nothing to convert. The operating system maps the file into the address space and pages it in on demand. A 30GB checkpoint "loads" in milliseconds, with actual disk reads happening lazily as tensors are touched.

Two consequences follow that matter in practice:

  • You can load one tensor from a huge file without reading the rest — useful for inspection, surgery on specific layers, and merging.
  • Several processes can share one mapping. Two inference workers on the same machine map the same physical pages rather than each holding a private copy.

⚠️ Alignment matters for mmap performance

For memory-mapped access to be efficient, tensor data should begin at an aligned offset. Writers typically pad the header so the buffer starts on an 8-byte boundary, and many pad further.

If you write safetensors by hand and skip this, files remain valid and readable — they just lose the zero-copy speed advantage that was the point. Pad the JSON header with spaces to reach your alignment.

The metadata slot, and its limits

One reserved key, deliberately constrained:

{ "__metadata__": { "format": "pt", "base_model": "some-org/some-model-7b", "trained_steps": "4000" // a STRING, note }, "model.layers.0.q_proj.weight": { ... } }

Strings to strings, flat, no nesting. Numbers must be stringified. This looks like an oversight and is a decision: a metadata section that could hold arbitrary structures would need a general parser, and general parsers are where formats acquire vulnerabilities. Keeping it to a flat string map means the header parser is a JSON parser and nothing more.

The practical cost is that anything structured — training config, tokeniser settings, quantisation parameters — lives in sidecar JSON files rather than in the checkpoint. Which is why a model repository is always a directory rather than a single file.

Sharded models

Large models are split across files, with an index describing the mapping:

model-00001-of-00004.safetensors model-00002-of-00004.safetensors model-00003-of-00004.safetensors model-00004-of-00004.safetensors model.safetensors.index.json // The index maps every tensor name to its shard { "metadata": { "total_size": 28966928384 }, "weight_map": { "model.embed_tokens.weight": "model-00001-of-00004.safetensors", "model.layers.0.q_proj.weight": "model-00001-of-00004.safetensors", "lm_head.weight": "model-00004-of-00004.safetensors" } }

Each shard is a complete, independently valid safetensors file. The index is a separate concern — which means a missing shard produces a clear "tensor not found" rather than a corrupt-file error, and shards can be downloaded in parallel or resumed individually.

Why it cannot execute anything

The security argument is often stated as "safetensors is safer than pickle", which undersells it. The formats are not on the same axis at all.

PickleSafetensors
What it isA stack-based VMA table of byte ranges
Can name a class or functionYesNo
Loading runs opcodesYesNo
Worst case on loadArbitrary codeInvalid tensor shapes

Pickle can execute code because executing code is what pickle does — reconstructing an object graph requires calling constructors. Safetensors cannot, because there is no field in which to put an instruction. The security property is structural rather than a matter of careful implementation, which is the strongest kind. The full comparison is in safetensors vs pickle.

🚨 Not executable does not mean not hostile

A malformed safetensors file can still cause problems: offsets that overlap, ranges extending past the end of the buffer, shapes whose element count does not match the byte range, or a header claiming a length that exceeds the file.

Good readers validate all of this. If you write your own, check that every range is within bounds, that shape × dtype size equals the range length, and that ranges do not overlap. The failure mode is a crash or nonsense output rather than compromise — but "safe format" is a claim about the worst case, not a promise that any file will work.

Inspecting a file yourself

The header-first layout means you can enumerate a remote 100GB model's full structure over HTTP with two range requests:

// 1. First 8 bytes → header length Range: bytes=0-7 // 2. Next N bytes → the complete JSON header Range: bytes=8-8187 // You now know every tensor name, shape and dtype // in the file, having downloaded ~8KB.

This is genuinely useful — verifying a model's architecture, checking quantisation, or confirming a download matches expectations, all without transferring the weights. Once you have the header, our JSON formatter makes it readable in the browser.

Reading a model header?

Format and explore JSON entirely in your browser — nothing is uploaded to a server.

Open JSON Formatter →

Summary

  • 8-byte length, JSON header, raw tensor buffer. That is the whole format.
  • Offsets are relative to the buffer — absolute position is 8 + header_len + offset.
  • Tensors are rectangular blocks of one numeric type. No strings, no nesting, no ragged shapes.
  • Loading is memory-mapping, not parsing — near-instant regardless of size.
  • Individual tensors can be read without touching the rest of the file.
  • __metadata__ is flat strings only, by design. Structure lives in sidecar files.
  • Shards are independently valid files plus a separate index.
  • It cannot execute code because there is no field for one — but still validate bounds.

Frequently Asked Questions

What is the structure of a safetensors file?

Three parts in order: an 8-byte little-endian unsigned integer giving the header length, then that many bytes of UTF-8 JSON describing every tensor, then the raw tensor data as one contiguous byte buffer. The JSON gives each tensor a dtype, a shape and a start and end offset into that buffer.

Why is safetensors faster to load than pickle?

Because the data can be memory-mapped rather than deserialised. Tensor bytes sit in the file in their final layout, so the operating system maps them into memory directly with no parsing, no object construction and no copying. Loading becomes close to instant regardless of file size.

Can I read a safetensors header without downloading the whole file?

Yes, and this is one of its most useful properties. Read the first 8 bytes to get the header length, then read that many more bytes to get the complete JSON description of every tensor. A few kilobytes tells you the full structure of a file that might be hundreds of gigabytes.

Can safetensors store arbitrary metadata?

Only limited metadata, deliberately. A reserved __metadata__ key in the header holds a flat map of string keys to string values — no nested structures, no other types. This is a design constraint that keeps the header simple and prevents metadata from becoming a vector for complex parsing.

Why can't safetensors execute code like pickle can?

Because the format has no mechanism for it. A safetensors file describes tensors with a dtype, a shape and byte offsets — there is no way to express an instruction, a class reference or a function call. Pickle is a stack-based virtual machine that constructs objects; safetensors is a table of numbers.

P

Written by Paras

We build free, browser-based file tools and write the reference material we wish existed when we were looking things up. Spotted an error? Tell us and we will fix it.