What Pickle Does When a Model Loads

The security boundary in pickle-based model loading is the moment when bytes become Python objects. This chapter focuses on that mechanism: how pickle’s instruction stream drives reconstruction, how referenced behavior enters the process, and why a PyTorch checkpoint can contain both ordinary tensor storage and a metadata layer that needs interpretation. Understanding this sequence lets a reader distinguish a known model architecture loaded from a state dictionary from an arbitrary object graph restored by a general-purpose deserializer.

The distinction matters because “serialization” sounds like storage and “deserialization” sounds like recovery. In pickle, recovery is an active computation. The loader reads a program-like representation and performs the operations needed to rebuild the object it describes. The operation may be legitimate, but its capabilities mean that the person or system that controls the serialized representation also influences what the loader attempts to do.

Deserialization is an interpreter, not a photocopier

A useful way to understand pickle is as a small stack-based instruction machine. When Python pickles an object, it records a sequence of operations and values. When Python unpickles it, an interpreter reads that sequence in order, places values on an internal stack, resolves references, builds arguments, and reconstructs the requested objects. The result may be a simple string or dictionary, but the mechanism is general enough to represent classes, functions, nested objects, and relationships among them.

That sequence creates three distinct stages that are easy to blur together:

  1. Serialization converts an in-memory object into a byte representation.
  2. Deserialization interprets that representation and rebuilds an object or value.
  3. Execution occurs when reconstruction resolves or invokes behavior as part of producing the result.

The first stage is performed by the publisher or producer. The second and third stages are performed in the consumer’s environment. That change of ownership is the security problem. A publisher can construct the artifact, but the consumer supplies the Python runtime, installed libraries, permissions, network access, and filesystem context in which the artifact is interpreted.

Pickle’s instruction set includes operations that can refer to objects in Python modules and operations that can invoke a callable with arguments. The USENIX research paper highlights GLOBAL, STACK_GLOBAL, and REDUCE as important examples. The names are less important than the relationship they represent: one part of the stream can identify behavior, another can provide inputs, and the unpickler can combine them during reconstruction. Constructors and special methods can matter for the same reason. An object does not have to contain a visibly separate program file for its reconstruction to cause behavior.

The sequence is easier to reason about if we separate a reference from the thing it references. A pickle stream can name a module-level object rather than embedding the entire implementation inside the file. The receiving Python environment then resolves that name against code already installed there. If the reference is used as a callable, the unpickler can pass reconstructed arguments to it as part of completing the object. This makes the environment part of the artifact’s effective meaning: the same byte sequence may resolve differently when libraries, versions, or available classes differ.

The process also explains why a scanner that merely searches for suspicious text can be incomplete. It must understand which references are resolved, which values reach them, and what constructors or special methods do with those values. Conversely, a scanner that executes the full object graph would be exposing itself to the behavior it is supposed to detect. Static inspection is therefore a deliberate compromise. It avoids the immediate execution risk, but it has to approximate runtime behavior and accept that some behavior is difficult to infer from the instruction stream alone.

This does not mean that every pickle file runs attacker-controlled code. A normal checkpoint may contain only data and the references needed to rebuild legitimate objects. The risk is conditional on what the serialized instructions request and what the receiving environment permits. But a consumer cannot determine that risk from the fact that the file is called “weights,” nor can they assume that a successful load was a passive read. The format gives the artifact a behavior-bearing role.

The safest mental model is therefore not “a model file is code.” That overstates the case. It is: a pickle-based model file is an input to an interpreter that may be able to invoke code while reconstructing an object. The distinction preserves both accuracy and urgency. It tells us why a malicious artifact is dangerous without claiming that all artifacts behave maliciously.

Inspection tools can use the same instruction structure in a more limited way. Hugging Face documents the use of pickletools to read pickle instructions without executing them and to extract referenced imports. That is a valuable separation: inspection can examine a representation without asking the full runtime to reconstruct it. Yet inspection is not execution, and an import list is not a complete behavioral proof. The scanner can tell a platform what names appear in the stream; it cannot automatically turn every possible composition of those names into a definitive safety judgment.

How a PyTorch checkpoint splits storage from reconstruction

PyTorch makes the abstract mechanism concrete. Its serialization documentation explains that torch.save and torch.load handle tensors as well as Python objects, and that Python pickle is involved in the object portion of the format. Since PyTorch 1.6, the usual file representation is an uncompressed ZIP64 archive. At a high level, the archive contains a data.pkl entry for the pickled object metadata and separate entries for tensor storage. The tensor bytes and the instructions that tell PyTorch how to reconstruct the saved object are related, but they are not the same thing.

This separation is useful for understanding the trust boundary. A tensor storage entry looks like the kind of numerical data a model needs. The data.pkl entry is the part that describes how the saved Python-side structure is reconstructed. A scanner or loader that understands only the outer archive has not necessarily analyzed the behavior of the inner representation. Conversely, a tool that inspects the inner pickle still has to understand the framework’s surrounding rules: which archive entries are expected, how devices are restored, and which objects the application will use after loading.

The distinction also explains why the phrase “the model is just weights” can be technically misleading. A workflow may contain just parameters in a carefully chosen representation, or it may serialize a complete module, configuration object, optimizer state, or other Python structure. The outer filename does not tell us which of these choices was made. What matters is the object that was passed to the saving routine and the loader behavior used later.

PyTorch’s documented recommendation is to save a module’s state_dict rather than the module object itself. A state dictionary is a mapping of parameter and persistent-buffer names to tensors. The consumer constructs a known module architecture and then restores that state into it. This pattern changes the job of deserialization. Instead of asking the loader to recreate an arbitrary class instance with its associated Python object graph, the consumer asks it to recover parameter data that will be applied to a structure the consumer already controls.

That is a smaller trust problem, not a magic safety guarantee. The model code still matters. The loader still matters. Dependencies and downstream operations still matter. A state dictionary can also be malformed or resource-intensive. But separating architecture from learned values removes an unnecessary reason to deserialize arbitrary behavior when the task only requires parameters.

The distinction becomes sharper with PyTorch’s restricted loading mode. Starting in version 2.6, when a custom pickle_module is not supplied, torch.load defaults to weights_only=True. The documentation describes this mode as using a restricted unpickler for state dictionaries and certain primitive types. Unlike the default Python unpickler, it is not allowed to dynamically import anything during unpickling. That narrows the remote-code-execution surface because the loader is no longer willing to reconstruct every Python object that an artifact might name.

The change is best understood as a change in permitted transitions. In a broad loading mode, the checkpoint can request a more general reconstruction process, subject to the runtime’s available objects and the loader’s implementation. In weights-only mode, the loader accepts a narrower class of objects. If an older checkpoint contains a class or function outside the default safe set, the load may fail and report what needs attention. The consumer then has a decision: verify the object and explicitly allowlist it, obtain a safer representation, or—only when the source and environment are trusted—use a less restricted path.

That failure is not merely an inconvenience. It is the loader exposing a boundary that was previously implicit. A successful unrestricted load can hide the fact that the artifact relied on arbitrary reconstruction. A failed restricted load forces the consumer to decide whether the extra behavior is necessary and trustworthy. The cost is compatibility: old or complex checkpoints may need changes, and some frameworks may choose less restrictive behavior to preserve existing workflows. Security and usability are both real requirements, so the design question is which side of the boundary should carry the uncertainty.

Allowlisting makes that decision explicit but does not make it automatic. PyTorch provides mechanisms to mark selected functions or classes as safe for a weights-only load, either by adding them to a safe set or by using a scoped context. The consumer is expected to inspect the code and trust the class or function before doing so. That is an important change in responsibility: the loader is no longer silently accepting every object named by the checkpoint, but the consumer can still enlarge the permitted surface when they decide compatibility requires it.

The documentation also describes a utility that statically reports globals not already safe for weights-only loading. Static reporting is useful for triage, yet it cannot reveal every type that will be built dynamically during unpickling. A clean static list therefore answers a narrower question—what recognizable globals did this inspection find?—than the question a consumer ultimately cares about—what objects and effects can occur in this environment? The gap is not an argument against the utility. It is a reason to combine it with known model code, a controlled dependency environment, and runtime containment.

A state dictionary narrows the same boundary from the producer’s side. It contains parameter values and persistent buffers associated with names in a known architecture. The consumer can review or fix the architecture separately, then apply the state to that structure. This makes the object being restored more predictable and makes incompatibility visible when the saved state does not fit the expected model. By contrast, serializing the complete module asks the loader to recover not only values but the Python-side relationships and behavior that define the object. The two workflows may both be described as “loading weights,” but they do not ask the runtime to do the same work.

sequenceDiagram participant C as Consumer participant L as Framework loader participant A as Checkpoint archive participant U as Unpickler participant M as Model or object graph C->>L: Request model load L->>A: Read archive entries A-->>L: data.pkl plus tensor storage L->>U: Interpret pickled metadata alt Restricted weights-only load U-->>L: Permit supported values only L->>M: Apply state to known model else General object reconstruction U->>M: Rebuild requested Python objects M-->>L: Return reconstructed structure end L-->>C: Loaded result or explicit failure

The sequence shows why a loader flag cannot be discussed without its surrounding workflow. In the restricted branch, the intended result is a known model receiving supported state. In the general branch, the loader may rebuild a richer object graph. Both branches can end in a returned object, but the transitions that produced that object—and therefore the consequences of a malicious artifact—are different.

The boundary also has limits. PyTorch states that weights_only=True does not protect against denial-of-service attacks, and it warns that memory-corruption concerns or dangerous downstream use may remain. Its static utility for listing unsafe globals can miss types created dynamically during unpickling. A restricted loader therefore reduces one important class of risk; it does not certify that the file is harmless, that the process cannot exhaust resources, or that later application code will use the result safely.

The practical conclusion is precise: when a workflow needs learned parameters, a known architecture plus a state dictionary is a narrower and more understandable loading contract than restoring an arbitrary module object. When compatibility requires richer pickle behavior, the consumer should treat that behavior as a deliberate trust decision and place it inside a controlled environment. The next question is how many different framework paths can lead to that interpreter before a scanner or consumer ever sees the true representation.

References