TidyRun Serialization Guide¶
Overview¶
This page covers serialization only: storing and retrieving Python objects
(serialize, deserialize, metadata, encoders, and LazyDict).
For deferred-compute APIs (DAG, Job, ParametrizedJob) and DAG execution
patterns, see the dedicated DAG Guide.
The TidyRun serialization framework provides a comprehensive, extensible system for storing and retrieving Python objects, including nested dictionaries, DataFrames, Series, and arbitrary Python objects, using a filesystem hierarchy.
Key Features¶
- Type-Aware Encoding: Automatically selects the best format (folder, parquet, HDF5, JSON, or pickle) based on value type
- Metadata Sidecars: Each output is accompanied by a
.tidyrunmetadata file recording encoding format, version, and checksum - Lazy Evaluation: Directories deserialize into
LazyDictobjects that load values on-demand on each access - Unified Path Handling: Paths are normalized via
cloudpathlib.AnyPathfor local and cloud-backed locations - Optional S3 Support:
s3://...locations work when theboto3extra is installed - Recursive Concatenation:
LazyDict.concat()method provides pandas-style aggregation across nested structures - Parallel Leaf Loading:
LazyDict.concat(max_workers=...)can load selected leaf values concurrently - Fallback Chain: Intelligent fallback routing (e.g., parquet → HDF5 when parquet encoding fails)
- Extensible Pipeline: Users can provide custom encoder sequences and compose encoders
- Direct Import Path: Use
tidyrun.serializationfor the serialization API
Quick Start¶
Basic Serialization¶
from tidyrun import serialize, deserialize
import pandas as pd
# Serialize a simple value
data = {"experiment": "run_1", "results": 42}
serialize(data, "./output/my_result")
# Deserialize back (returns LazyDict for folders)
loaded = deserialize("./output/my_result")
print(loaded["experiment"]) # "run_1" — loaded on access
print(loaded["results"]) # 42
DataFrame Support¶
import pandas as pd
df = pd.DataFrame({"x": [1, 2, 3], "y": ["a", "b", "c"]})
# Serializes to Parquet (with metadata sidecar)
serialize(df, "./output/dataframe")
# Deserializes directly as DataFrame
loaded_df = deserialize("./output/dataframe")
Nested Data with LazyDict¶
# Nested dictionary
nested = {
"run_1": {"metrics": pd.DataFrame(...)},
"run_2": {"metrics": pd.DataFrame(...)},
}
serialize(nested, "./outputs/runs")
# Deserialize as LazyDict — values load on access
loaded = deserialize("./outputs/runs")
# Lazy access
metrics_1 = loaded["run_1"]["metrics"] # Loaded only when accessed
# Recursive materialization
full_dict = loaded.to_dict() # Materializes all nested values
DAG APIs¶
For deferred-compute APIs (DAG, Job, ParametrizedJob) and local
multithreaded DAG evaluation, see the dedicated DAG page:
DAG Guide.
Optional S3 Support¶
TidyRun can read and write s3://bucket/prefix/... locations when the optional S3 dependency is installed:
pip install tidyrun[s3]
Under the hood, S3 serialization stages through a local temporary directory and then uploads the generated files. Deserialization downloads the object tree to a temporary local directory and then reuses the normal local loader.
For tests and local development, the S3 round-trip test suite uses the moto mock backend.
Concatenation Across Runs¶
# Given a nested structure like:
# outputs/
# run_1/
# metrics.parquet
# run_2/
# metrics.parquet
# ...
loaded = deserialize("./outputs")
# Concatenate all DataFrames, keyed by run ID
combined = loaded.concat(names=["run_id"])
# Returns: DataFrame with multi-index (run_id) and all metrics stacked
# With transformation (e.g., add timestamp)
combined = loaded.concat(
names=["run_id"],
transform=lambda df: df.assign(loaded_at=pd.Timestamp.now())
)
# Transform to scalar values (wrapped as a one-row "value" column)
totals = loaded.concat(
names=["run_id"],
transform=lambda df: df["metric"].sum(),
)
# With filtering (load only specific runs)
combined = loaded.concat(
names=["run_id"],
select=lambda path: path[0] in ["run_1", "run_2"]
)
# Parallel loading of leaf values (useful when leaf files are large)
combined = loaded.concat(
names=["run_id"],
max_workers=8,
)
# select(path) argument:
# - path: tuple of keys to the leaf (e.g. ("run_1", "metrics"))
# Note: select is evaluated at multiple depths while traversing nested folders.
# If you index deeper elements like path[1], guard with len(path) first.
Quick Reference¶
One-Minute Overview¶
TidyRun serialization provides save/load behavior with automatic format selection:
from tidyrun import serialize, deserialize
serialize({"df": my_dataframe, "config": settings}, "./output/result")
result = deserialize("./output/result")
df = result["df"]
What Gets Stored Where¶
| Type | Format | File | Notes |
|---|---|---|---|
dict |
Folder tree | key/name/ |
Keys encoded via TOML; values recurse |
pd.DataFrame |
Parquet | *.parquet |
Falls back to HDF5 on failure |
pd.Series |
Parquet | *.parquet |
Falls back to HDF5 on failure |
Scalar (int, str, bool, float, date, datetime, time) |
JSON | *.json |
JSON-serializable scalars |
| Other objects | Pickle | *.pickle |
Last resort |
Every output gets a .tidyrun metadata sidecar recording the selected format and checksum.
LazyDict in Practice¶
Directories deserialize as LazyDict objects for on-demand loading:
result = deserialize("./large_output")
value = result["key"]
full_dict = result.to_dict()
Encoder Fallback Chain¶
If one encoder cannot serialize a value, the next compatible encoder is tried:
dict-> folder treeDataFrame-> parquet, then HDF5Series-> parquet, then HDF5- JSON-serializable values -> JSON
- Everything else -> pickle
Common Patterns¶
Save experiment results:
serialize(
{
"config": hyperparams,
"metrics": pd.DataFrame(training_log),
"model": trained_model,
},
"./experiments/exp_001",
)
Load and compare runs:
runs = deserialize("./experiments")
comparison_table = runs.concat(names=["exp_id"])
API Summary¶
| Function | Purpose |
|---|---|
serialize(value, target, encoders=None) |
Save a value to disk and return ChecksumInfo |
deserialize(source, encoders=None) |
Load a value from disk |
LazyDict.to_dict() |
Materialize a nested LazyDict |
LazyDict.concat(names, transform, select, max_workers=None) |
Recursively concatenate DataFrames with optional parallel leaf loading |
encode_key(key) |
Encode a Python type to a filename-safe key |
decode_key(name) |
Decode a stored key name |
Architecture¶
Module Structure¶
src/tidyrun/serialization/
├── __init__.py # Public API exports
├── types.py # Type definitions, exceptions, constants
├── paths.py # Path/location helpers
├── metadata.py # Metadata I/O and format mapping
├── encoders.py # Encoder implementations (dict, parquet, hdf5, etc.)
├── lazy_dict.py # LazyDict class
└── api.py # Main serialize/deserialize functions
Encoder Pipeline¶
The default encoder pipeline tries encoders in this order:
- dict-folder: Maps dictionaries to directory trees (keys become folder names)
- dataframe-parquet: Stores DataFrames as
.parquetfiles - series-parquet: Stores pandas Series as
.parquetfiles - pandas-hdf5: Fallback for DataFrames/Series (HDF5 format with key
"data") - fallback-json: JSON serialization for scalar types (int, float, str, list, dict, etc.)
- fallback-pickle: Last resort for arbitrary Python objects
Encoders are tried in order; the first whose predicate returns True is used.
Fallback Mechanism¶
When an encoder fails (e.g., parquet cannot serialize a multi-index DataFrame), it raises GoToNextEncoderException to signal "skip me and try the next one." This allows:
- DataFrame with multi-index → fails parquet → tries HDF5 ✓
- Series when parquet engine unavailable → fails parquet → tries HDF5 ✓
- Custom object → fails all structured formats → falls back to pickle ✓
Metadata Sidecars¶
Every serialized value gets a .tidyrun metadata file:
# output.tidyrun
version = 1
encoding = "dataframe-parquet"
suffix = ".parquet"
[checksum]
algorithm = "sha256"
digest = "..."
This metadata:
- Tracks the encoding format used
- Enables schema versioning for future compatibility
- Allows deserialization without requiring file extension guessing
API Reference¶
Core Functions¶
tidyrun.serialization.api.serialize(value, target, encoders=None)
¶
Serialize a Python value to disk using the configured encoder pipeline.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
value
|
Any
|
The value to serialize (dict, DataFrame, Series, scalar, etc.). |
required |
target
|
str | Path | CloudPath
|
Where to write the output. Extension-free; the encoder appends the
appropriate suffix. Accepts local paths or |
required |
encoders
|
Iterable[EncoderSpec] | None
|
Custom encoder pipeline. Defaults to :func: |
None
|
Returns:
| Type | Description |
|---|---|
ChecksumInfo
|
Checksum ( |
Raises:
| Type | Description |
|---|---|
TidyRunSerializationError
|
When no encoder matches the value type. |
NotImplementedError
|
When an S3 target is requested without the optional dependency. |
Source code in src/tidyrun/serialization/api.py
143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 | |
tidyrun.serialization.api.deserialize(source, encoders=None)
¶
Deserialize a value from disk using metadata to determine the format.
Directories encoded as dict-folder are returned as :class:LazyDict
objects whose values are loaded on first access.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
source
|
str | Path | CloudPath
|
Location to read from. Accepts local paths or |
required |
encoders
|
Iterable[EncoderSpec] | None
|
Custom encoder pipeline. Defaults to :func: |
None
|
Returns:
| Type | Description |
|---|---|
Any
|
|
Raises:
| Type | Description |
|---|---|
TidyRunDeserializationError
|
When metadata is missing, the encoder name is unknown, or the payload cannot be read. |
Source code in src/tidyrun/serialization/api.py
212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 | |
LazyDict¶
tidyrun.serialization.lazy_dict.LazyDict
¶
Bases: Mapping[Key, Any]
Dictionary-like object that loads each child value on first access.
Source code in src/tidyrun/serialization/lazy_dict.py
37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 | |
__getitem__(key)
¶
Source code in src/tidyrun/serialization/lazy_dict.py
93 94 95 96 97 98 99 100 101 102 103 104 | |
to_dict()
¶
Source code in src/tidyrun/serialization/lazy_dict.py
117 118 119 120 121 122 123 124 125 | |
concat(names=None, transform=None, select=None, max_workers=None)
¶
Concatenate leaf values from a nested LazyDict.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
names
|
list[str | None] | None
|
Names for the MultiIndex levels built from nested keys. Pass
|
None
|
transform
|
Callable[[Any], Any] | None
|
Optional function applied to each selected leaf value before
concatenation. The result may be a |
None
|
select
|
Callable[[tuple[Key, ...]], bool] | None
|
Optional predicate called as |
None
|
max_workers
|
int | None
|
When set, leaf values are loaded in parallel using a
:class: |
None
|
Returns:
| Type | Description |
|---|---|
DataFrame
|
Result of |
Raises:
| Type | Description |
|---|---|
ValueError
|
When a |
Source code in src/tidyrun/serialization/lazy_dict.py
127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 | |
Exceptions¶
TidyRunSerializationError¶
Raised when serialization fails (e.g., no encoder matches the value type).
from tidyrun.serialization import TidyRunSerializationError
try:
serialize(some_unsupported_type(), "./output")
except TidyRunSerializationError as e:
print(f"Cannot serialize: {e}")
TidyRunDeserializationError¶
Raised when deserialization fails (e.g., missing metadata, invalid format).
from tidyrun.serialization import TidyRunDeserializationError
try:
deserialize("./invalid_path")
except TidyRunDeserializationError as e:
print(f"Cannot deserialize: {e}")
Key Encoding¶
TidyRun keys (used as folder/file names in the hierarchy) are encoded using TOML for type safety.
tidyrun.keys.encode_key(key)
¶
Encode a Python key value to a path-safe string using TOML.
Supported types are str, int, float, bool, date,
datetime, and time. Plain strings that round-trip unambiguously
through TOML are left unquoted; strings that would otherwise be
interpreted as another type (e.g. "true", "42") are
TOML-quoted.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
key
|
Key
|
The key value to encode. |
required |
Returns:
| Type | Description |
|---|---|
str
|
A non-empty string suitable for use as a filesystem path component. |
Raises:
| Type | Description |
|---|---|
TidyRunKeyEncodingError
|
When the key type is not supported or the resulting name violates
path constraints (empty, contains |
Examples:
>>> encode_key(42)
'42'
>>> encode_key("hello")
'hello'
>>> encode_key("true")
'"true"'
>>> encode_key(True)
'true'
Source code in src/tidyrun/keys.py
46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 | |
tidyrun.keys.decode_key(name)
¶
Decode a stored key name back to its original Python type.
Reverses the encoding produced by :func:encode_key.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
name
|
str
|
The encoded key name (a filesystem path component). |
required |
Returns:
| Type | Description |
|---|---|
Key
|
The original Python value ( |
Raises:
| Type | Description |
|---|---|
TidyRunKeyDecodingError
|
When the name is empty, contains path separators, or cannot be decoded to a supported key type. |
Examples:
>>> decode_key("hello")
'hello'
>>> decode_key('"true"')
'true'
>>> decode_key("42")
42
Source code in src/tidyrun/keys.py
119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 | |
Customization¶
Custom Encoder¶
To add support for a custom type:
from tidyrun.serialization import EncoderSpec, serialize
def is_my_type(value):
return isinstance(value, MyType)
def encode_my_type(value, target):
# Write value to target location
...
def decode_my_type(source):
# Read and return value from source location
...
my_encoder = EncoderSpec(
name="my-custom-type",
predicate=is_my_type,
serializer=encode_my_type,
deserializer=decode_my_type,
)
# Use custom encoder
from tidyrun.serialization import default_encoders
custom_pipeline = (my_encoder,) + default_encoders()
serialize(my_value, "./output", encoders=custom_pipeline)
Custom Encoder Pipeline¶
To override the default pipeline order:
from tidyrun.serialization import default_encoders, EncoderSpec
# Reorder: put HDF5 before Parquet
encoders = default_encoders()
reordered = (
encoders[0], # dict-folder
encoders[3], # pandas-hdf5
encoders[1], # dataframe-parquet
*encoders[2:], # rest
)
serialize(df, "./output", encoders=reordered)
Performance Considerations¶
Lazy Loading¶
LazyDict does not load values until accessed:
loaded = deserialize("./large_structure") # Fast: only reads metadata
result = loaded["expensive_dataframe"] # Slow: loads large file here
result = loaded["expensive_dataframe"] # Loaded again on access
Concatenation Memory¶
concat() materializes all selected leaves into memory before concatenating. For very large structures, consider filtering with the select parameter:
# Load all 1000 experiments at once (high memory)
combined = loaded.concat()
# Load only 10 specific experiments (low memory)
combined = loaded.concat(
select=lambda path: path[0] in experiments[:10]
)
Parquet Engine Selection¶
Parquet serialization uses pyarrow by default; fastparquet is tried if pyarrow is unavailable. HDF5 is tried if parquet encoding fails.
Limitations and Future Work¶
Current Limitations¶
- Remote Storage: S3 is supported as an optional backend via
tidyrun[s3]. Other cloud storage providers such as GCS and Azure Blob Storage are not yet supported. - Schema Evolution: No automatic schema migration for DataFrames. Users must handle schema changes manually (e.g., use
transforminconcat). - Parquet Multi-Index: Multi-index DataFrames cannot be serialized to parquet and fall back to HDF5.
Planned Features¶
- Custom Metadata: Allow users to store arbitrary metadata alongside outputs
Testing¶
Run the full test suite:
pixi run pytest tests/serialization tests/test_keys.py
Key test modules:
tests/serialization/test_api.py: End-to-end serialize/deserialize, metadata, fallback sequencing, S3 round-triptests/serialization/test_encoders.py: Encoder predicates and detection logictests/serialization/test_lazy_dict.py: LazyDict access patterns and concatenationtests/test_keys.py: Key encoding and decoding
Examples¶
Example 1: Saving Experiment Results¶
import pandas as pd
from tidyrun import serialize, deserialize
# After running an experiment
results = {
"config": {"lr": 0.001, "epochs": 100},
"metrics": pd.DataFrame({
"epoch": [1, 2, 3],
"loss": [0.5, 0.3, 0.2]
}),
"model_weights": some_large_array, # Will pickle
}
serialize(results, "./experiments/exp_001")
# Later, load with lazy access
loaded = deserialize("./experiments/exp_001")
print(loaded["config"]) # Loaded on access
model = loaded["model_weights"] # Pickled data
Example 2: Comparing Multiple Runs¶
runs = {
"run_a": {
"metrics": pd.DataFrame({"accuracy": [0.8, 0.85, 0.9]}),
},
"run_b": {
"metrics": pd.DataFrame({"accuracy": [0.75, 0.82, 0.88]}),
},
}
serialize(runs, "./comparison")
# Load and aggregate
loaded = deserialize("./comparison")
combined = loaded.concat(names=["run_id"])
print(combined)
# Output:
# accuracy
# run_id
# run_a 0 0.80
# 1 0.85
# 2 0.90
# run_b 0 0.75
# 1 0.82
# 2 0.88
Example 3: Filtered Concatenation¶
# Load results from multiple time periods
results = deserialize("./results_by_month")
# Concatenate only 2026 results, adding month info
combined = results.concat(
names=["month"],
select=lambda path: path[0].startswith("2026"),
transform=lambda df: df.assign(period="2026")
)