Stores & persistence
A store is where the BenchmarkRunner persists every BenchmarkRecord after execution, the third of qpubench’s three layers (schemas describe, adapters execute, stores persist). qpubench ships three stores. All implement the ResultStore protocol:
class ResultStore(Protocol):
def save(self, record: BenchmarkRecord) -> None: ...
def load(self, experiment_id: str) -> BenchmarkRecord: ...
def query(self, **filters) -> list[BenchmarkRecord]: ...
NDJSONStore
Zero-dependency, append-only, one JSON record per line.
Suitable for streaming writes, grep-based ad-hoc queries, and compatibility with any NDJSON tool.
import pathlib
from qpubench import BenchmarkRunner, NDJSONStore
store = NDJSONStore(pathlib.Path("results/sweep.ndjson"))
runner = BenchmarkRunner(store=store)
Querying
Keys use __ as a field-path separator:
# Filter by backend name
records = store.query(backend__name="aer_statevector")
# Multiple filters (AND)
records = store.query(result__status="succeeded", vqa__molecule="H2")
# Algorithm-driven runs
records = store.query(options__algorithm_spec__name="ADAPTVQE")
# Load a single record by UUID
record = store.load("3f2a1b4c-...")
# All records
all_records = store.all()
The query matches on the model_dump() representation of BenchmarkRecord. Nested dicts use the same __ convention, e.g. result__expectation_values would require a further traversal that the query engine does not yet support; load the record and filter in Python for deep nesting.
Thread safety
Individual write() calls on CPython are atomic for lines shorter than PIPE_BUF (~4 KB on Linux). For large records or concurrent writers, use an explicit file lock or separate per-worker files.
ParquetStore
Columnar store backed by a single Parquet file. Requires pip install 'qpubench[storage]'.
from qpubench.store import ParquetStore
import pathlib
store = ParquetStore(pathlib.Path("results/sweep.parquet"))
Records are flattened one level deep before writing. A _raw_json column stores the full JSON for lossless round-trips.
save() rewrites the whole file per record (Parquet files are immutable), so treat this as an export/analysis format: collect records during a sweep with NDJSONStore, then bulk-load them with store.save_many(records) (one rewrite total).
# Flat pandas DataFrame for analysis and plotting
df = store.to_dataframe()
print(df[["molecule", "final_eigenvalue", "circuit_depth"]].describe())
# Query, column-level only: filters use flat single-underscore column
# names (not NDJSONStore's dotted __ paths); unknown columns are ignored
records = store.query(backend_name="aer_statevector")
records = store.query(result_status="succeeded")
# Load by UUID (uses _raw_json → BenchmarkRecord round-trip)
record = store.load("3f2a1b4c-...")
Flat column names produced from a BenchmarkRecord:
| Column | Source |
|---|---|
schema_version, experiment_id, run_id, timestamp |
Top-level |
num_qubits, circuit_depth, tags, notes |
Top-level |
circuit_computing_model, circuit_qubit_modality, circuit_format |
circuit.* |
backend_name, backend_provider |
backend.* |
shots, opt_level, error_mitigation |
options.* |
result_status, qpu_time_s, total_time_s |
result.* |
molecule, basis, ansatz |
vqa.* (inputs) |
final_eigenvalue, ground_truth |
vqa_result.* (computed outputs) |
_raw_json |
Full JSON (for load() round-trip) |
S3Store
Object store backed by S3 or any S3-compatible endpoint. Requires pip install 'qpubench[s3]'.
One JSON object per record, f"{prefix}/{experiment_id}.json", instead of NDJSONStore’s shared append-only file. There is no read-modify-write of shared state, so concurrent writers (separate processes, machines, or a distributed sweep) do not race. Only put_object, get_object, and the list_objects_v2 paginator are used, so it works unmodified against real AWS S3 and against S3-compatible gateways.
AWS S3
from qpubench import BenchmarkRunner, S3Store
store = S3Store(
"my-results-bucket",
prefix="sweeps/2026-07-06",
region_name="eu-west-1",
# aws_access_key_id / aws_secret_access_key are optional; omit them to
# use the standard boto3 credential chain (env vars, ~/.aws/credentials,
# instance role, etc.)
)
runner = BenchmarkRunner(store=store)
Any keyword accepted by boto3.client("s3", ...) can be passed through directly (endpoint_url, region_name, aws_access_key_id, aws_secret_access_key, config=botocore.config.Config(...)), which also makes S3Store work against MinIO or any other S3-compatible service. Pass an already-constructed client instead via client=:
import boto3
store = S3Store("my-bucket", client=boto3.client("s3", endpoint_url="http://localhost:9000"))
Hugging Face Storage Buckets
Storage Buckets expose an S3-compatible gateway at https://s3.hf.co/<namespace>. S3Store.huggingface() sets up the required botocore Config (path-style addressing, single region, ListObjectsV2-only) for you:
from qpubench import S3Store
store = S3Store.huggingface(
bucket="my-training-bucket",
namespace="my-username", # or an organization name
access_key_id="HFAK...", # Settings → Access Tokens →
secret_access_key="...", # (token) → Generate S3 credentials
prefix="qpubench-results",
)
runner = BenchmarkRunner(store=store)
Generate the S3 credentials from a Hugging Face User Access Token with Write permission (its dropdown menu has a Generate S3 credentials action); the bucket itself must already exist (hf buckets create my-training-bucket, or via huggingface_hub.create_bucket).
Loading and querying
Identical API to NDJSONStore: save(), load(experiment_id), query(**filters), all():
record = store.load("3f2a1b4c-...")
records = store.query(backend__name="aer_statevector", result__status="succeeded")
all_records = store.all() # lists every object under the prefix, then fetches each
query()/all() list every object under the store’s prefix and fetch each in turn, fine for benchmark-scale result sets, but there is no server-side filtering, so very large buckets should be partitioned by prefix (e.g. one prefix per run_id) to keep listings small.
Hooks
Hooks run after every BenchmarkRecord is finalised but before it is saved to the store. Use them for live monitoring, intermediate logging, or side-channel metrics.
def log_energy(record):
ev = record.result.expectation_values
val = f"{ev[0].value:.6f}" if ev else "n/a"
print(f"[{record.backend.name}] {record.vqa.molecule or '?'} E={val} "
f"t={record.result.total_time_s:.2f}s")
runner.add_hook(log_energy)
Multiple hooks are called in registration order. An exception inside a hook is logged but does not abort the run or prevent persistence.
# Hooks can also save intermediate state to a separate file
import json, pathlib
log_path = pathlib.Path("live_log.jsonl")
def side_log(record):
with log_path.open("a") as f:
f.write(json.dumps({
"id": record.experiment_id,
"E": record.result.expectation_values[0].value
if record.result.expectation_values else None,
"status": record.result.status.value,
}) + "\n")
runner.add_hook(side_log)
Sweep + store pattern
records = runner.sweep(
circuits=[circuit_a, circuit_b],
backend_names=["stub_gate", "aer_statevector"],
options_list=[ExecutionOptions(shots=s) for s in [512, 2048, 8192]],
run_id="h2_shots_sweep",
tags=["vqe", "h2", "shots_sweep"],
)
# All 12 records (2×2×3) are saved automatically; group by run_id:
group = store.query(run_id="h2_shots_sweep")
print(f"{len(group)} records in sweep")