Saving the state of the deck to disk can be phenomenally useful for error recovery. PLR serializes two different things, through two different pairs of calls.
Layout is the resource tree: which labware exists, what type it is, and where it sits. save() writes it, load_from_json_file() reads it.
State is what is in that labware: volumes in wells, tips in spots. save_state_to_file() writes it, load_state_from_file() reads it.
Loading a layout gives you an empty deck of the right shape. The volumes come back only if you also load the state.
Two wells with known contents, so there is something for the state file to carry.
13.1 Save a deck layout
Write the deck to a file, and rebuild it later without re-running the assignment code.
layout = pathlib.Path("layout.json")lh.deck.save(str(layout), indent=2)raw = json.loads(layout.read_text())print("type: ", raw["type"])print("children:", [c["name"] for c in raw["children"]])print("bytes: ", layout.stat().st_size)
1
save() is on Resource, so any resource can be written, not just a deck. indent is passed through to json.dump.
2
The type field holds the class name, which is what reconstruction looks up.
Loading re-runs assign_child_resource for every resource in the file, so any warning the deck emits at assignment time is emitted again — here, the tall-tip-rack traverse warning that the setup cell already produced once.
The file is large.
A STARlet deck with one plate and one tip rack serializes to roughly 300 KB, because every well and every tip spot is a resource with its own entry. This is a description of the deck, not a diff.
See also: the visualizer in chapter 1 renders from this same serialize() output.
13.2 Save and restore contents
Record what is in the wells, and put it back on a deck that has already been rebuilt.
WarningGotcha: state is keyed by name, so names are the contract
load_state matches entries to resources by name. Rename a plate between the save and the load and its wells no longer match, so the volumes are silently not restored — there is no error, because a tree that lacks a key simply has nothing applied to it.
See also:set_volume() in chapter 5 writes the same tracker directly, without a file.
13.3 Why layout and state are two files
See what each file restores, by loading them one at a time.
The layout rebuilds the plate, the wells, and their positions — with empty trackers.
2
The state fills the trackers in.
2026-08-27 22:14:07,023 - pylabrobot - WARNING - Resource 'rack' is very high on the deck: 279.19 mm. Be careful when traversing the deck.
after layout only: 0
after state too: 150
and B1: 75
Which file changes when.
The layout changes when you assign, unassign, or move labware. The state changes on every aspirate, dispense, pick-up, and drop. Saving state mid-run is cheap relative to the layout: about 100 KB against 300 KB for the deck above, and the layout is usually identical between runs.
See also: telling PLR about an off-deck move is a layout change (chapter 9); telling it a trough was filled by hand is a state change.
13.4 Load a layout that uses classes you defined
Reconstruct a deck whose type fields name classes that are not part of PLR.
from pylabrobot.utils.object_parsing import find_subclassprint("Plate: ", find_subclass("Plate", Resource))print("NotAnyClass: ", find_subclass("NotAnyClass", Resource))
1
Reconstruction resolves a type string to a class with find_subclass.
2
An unknown name resolves to None, and deserialize turns that into a ValueError.
Standing in for a layout saved by code that defined its own carrier class.
ValueError: Could not find subclass with name MyCustomCarrier
WarningGotcha: import your own classes before loading
For labware you define yourself (chapter 17), the class must be imported into the loading process beforeload_from_json_file runs, even though the file names it. The import is what registers it.
PLR’s own definitions are exempt in practice because pylabrobot.resources.__init__ imports every vendor package, so the classes are registered as soon as you import anything from resources.
Python you need here
SomeClass.__subclasses__() returns direct subclasses only, which is why find_subclass recurses. The list is populated as a side effect of class creation, so it reflects what has been imported — not what exists on disk.
See also:deserialize(..., allow_marshal=True) additionally reconstructs functions from the file. It is off by default because it executes data from the file as code.
13.5 What to remember
Layout = save() / load_from_json_file(): the tree, types, and positions. State = save_state_to_file() / load_state_from_file(): volumes and tips.
Loading a layout gives empty trackers. Load state to fill them.
The layout is nested and keyed by structure; the state is flat and keyed by resource name. A renamed resource silently gets no state back.
serialize_state() is one resource, serialize_all_state() is the subtree.
Reconstruction resolves type through find_subclass, which only sees imported classes — import your own before loading, or get ValueError: Could not find subclass with name ....