Robot data tutorial
LeRobot Dataset Format Explained: Episodes, Timestamps and Validation
Understand LeRobot dataset v3 metadata, Parquet, video and episode timing. Run a small Python checker on synthetic numeric data and inspect its limits.
LeRobot dataset v3.0 separates storage files from episodes: multiple episodes can share Parquet and MP4 shards, while metadata identifies each episode and its offsets. A filename is not an episode boundary.
Start by checking what each observation and action means, then check shapes, timestamps and episode coverage. Passing a numeric check does not establish that a policy can use the data.
Checked on September 16, 2026. The PyArrow-only numeric checker passed 16 behavior tests on Windows x64. A separate LeRobot 0.6.1 exercise also wrote and loaded 8 numeric frames across 2 episodes through the official writer and loader, and rejected missing action and wrong-shape inputs. Neither exercise tests video decoding, policy training, real hardware or complete format compatibility.
Which version does this guide describe?
This guide describes the v3.0 dataset format. That format identifier is separate from the LeRobot Python package version. The official main-branch documentation and source were checked on September 16, 2026; the inspected source commit is 89236ea0f4f81a81ca566081e20dd1ff5f823cbe and its CODEBASE_VERSION is v3.0.
The documentation still contains older installation and migration language. Its simplified layout lists meta/tasks.jsonl, whereas the inspected implementation uses meta/tasks.parquet and names tasks.jsonl as a legacy path. Use the metadata and code revision belonging to your dataset; do not mix v2.1 per-episode filenames with v3 shard offsets. The exercise below needs PyArrow only and does not install LeRobot.
Read the storage layout before a training sample
The layout below is a simplified map of a v3 dataset with video, not the contents of our deliberately smaller fixture. A shard may hold several episodes. Camera streams use their own files, and an episode is reconstructed through metadata rather than by pairing similarly named files.
| Component | What it describes | What to inspect |
|---|---|---|
| meta/info.json | Format version, FPS, feature names, shapes, dtypes and path templates. | Read features and data_path/video_path before assuming a vector order or camera layout. |
| meta/stats.json | Feature statistics used by normalization pipelines. | Check the population and split used to compute them; statistics are outside this checker. |
| meta/tasks.parquet | Task text and task identifiers in the inspected implementation. | A numeric task_index alone does not explain the instruction. Task-text mapping is outside this exercise. |
| meta/episodes/ | Episode length, global row boundaries and shard references; video-related offsets when present. | dataset_from_index is inclusive; dataset_to_index is exclusive. Do not infer boundaries from filenames. |
| data/ | Per-frame state, action, timestamp and index columns in Parquet. | Match each vector to its feature specification and each row to the episode table. |
| videos/ | Encoded camera streams, potentially shared by several episodes. | Use camera-specific metadata and timestamps; decoding and frame alignment require separate tests. |
meta/info.json
meta/stats.json
meta/tasks.parquet
meta/episodes/chunk-000/file-000.parquet
data/chunk-000/file-000.parquet
videos/observation.images.front/chunk-000/file-000.mp4State, action and time are different contracts
observation.state records the observed robot state. action describes the target used by a particular controller or policy. They can have different dimensions and units. The format alone cannot tell you whether an action is a joint target, pose delta or another command. Record coordinate frames, dimension order and normalization alongside the dataset.
The inspected writer generates frame_index within each episode and timestamp = frame_index / fps. timestamp is therefore an episode-relative time in seconds in this workflow; it resets between episodes. It is not a UNIX timestamp, a sensor capture timestamp or a measured end-to-end delay. Preserve capture and receive clocks separately when collecting real streams, document how they are aligned, and retain alignment residuals before resampling.
| Fixture field | Meaning | Unit or validity |
|---|---|---|
| index / episode_index / frame_index / task_index | Global row / episode / row within episode / task identifier. | Non-negative integers; task text is not supplied in this numeric subset. |
| timestamp | frame_index / 10 in two separate four-frame episodes. | Seconds; 0, 0.1, 0.2, 0.3 within each episode. |
| observation.state [3] | Synthetic x, y, z position. | Metres in an invented demonstration frame; not measured robot state. |
| action [2] | Synthetic dx, dy values for inspecting a two-dimensional vector. | Metres; illustrative data only, never sent to a robot. |
| observation.tactile [4] | Four independently designed teaching taxels in row-major 2 × 2 order. | Arbitrary units, not newtons or pressure; an invalid sample remains unknown. |
| observation.tactile_valid [4] | Boolean validity for the four custom tactile values. | One false flag accompanies a null; a null marked true is an error. |
Download the complete numeric exercise
The ZIP includes check_dataset.py, generate_fixtures.py, test_validator.py, requirements.txt, two tiny Parquet fixtures, an example field contract and the reports produced in our run. All data is independently generated synthetic data. It does not reuse robot demonstrations or represent a physical tactile sensor.
This is intentionally not a complete loadable LeRobot dataset: the fixtures omit task-text metadata, statistics, images and videos. The checker validates a small numeric snapshot using v3 field and boundary conventions. Use it to learn failure modes or adapt the checks to an export; use the official loader and your policy pipeline for subsequent compatibility checks.
The only third-party dependency is pyarrow==23.0.1. The recorded environment is CPython 3.13.3 on Windows x64. No robot, tactile sensor, GPU, ROS installation, Hugging Face account or model download is required. Code is Apache-2.0; generated fixtures and example contracts are CC0-1.0. Third-party datasets keep their own licenses.
Run the checker and inspect both outcomes
Extract the ZIP into a fresh directory and open a terminal there. The commands read the included fixtures and write JSON reports to a separate results directory. The invalid run intentionally exits with code 1; this is the expected failure demonstration. A passing run exits with code 0.
PowerShell commands below use the virtual environment directly, so activation and execution-policy changes are unnecessary. On macOS or Linux, use the second block; that command variant has not been executed in this Windows verification environment.
python -m venv .venv
.\.venv\Scripts\python.exe -m pip install -r requirements.txt
.\.venv\Scripts\python.exe -B check_dataset.py fixtures/valid --report results/valid-report.json
.\.venv\Scripts\python.exe -B check_dataset.py fixtures/invalid --report results/invalid-report.json
.\.venv\Scripts\python.exe -B -m unittest -v test_validatorpython3 -m venv .venv
.venv/bin/python -m pip install -r requirements.txt
.venv/bin/python -B check_dataset.py fixtures/valid --report results/valid-report.json
.venv/bin/python -B check_dataset.py fixtures/invalid --report results/invalid-report.json
.venv/bin/python -B -m unittest -v test_validatorPASS (numeric subset): 8 frames, 2 episodes, 0 errors, 1 unknown tactile values
FAIL (numeric subset): 8 frames, 2 episodes, 17 errors, 1 unknown tactile valuesWhat the checks catch, and what a pass leaves open
The valid fixture has eight frames across two episodes in one data shard. One tactile value is explicitly unknown and is not replaced with zero. The invalid fixture removes action, shortens one state vector, repeats a timestamp and corrupts an episode endpoint. Some faults cause several diagnostics, so the error count is not a count of independent defects.
The default time tolerance is 0.0001 seconds against frame_index/fps. This is an exercise consistency tolerance, not a synchronization guarantee for real sensors. The checker loads all rows into memory, expects a complete contiguous snapshot starting at index and episode zero, and accepts one-dimensional numeric/boolean features only. It is not a streaming validator for a large production corpus.
| Check | Detected failure | Limit |
|---|---|---|
| Required fields and finite values | Missing action/state fields, null valid measurements, NaN and invalid index values. | Does not certify Arrow dtype conformance or measurement accuracy. |
| Feature dimensions | Vector lengths that disagree with features in info.json. | Cannot determine whether a joint order or coordinate frame is physically correct. |
| Episode-relative timing | Duplicate/backward times or disagreement with the declared fixed-rate grid. | Cannot measure original capture jitter, clock offsets or sensor latency from resampled rows. |
| Episode boundaries and shard references | Gaps, overlaps, inconsistent lengths, unmatched frame/episode IDs and incomplete row coverage. | Requires a complete small snapshot, not an arbitrary episode selection. |
| Explicit unsupported inputs | Video layout and nonnumeric or multidimensional feature declarations. | No video decoding, task-text mapping, statistics, train/test leakage or policy compatibility validation. |
Add tactile fields only with an explicit consumer
observation.tactile and observation.tactile_valid are RoboSkin example field designs. They are not a universal LeRobot tactile schema, and a policy does not gain tactile support merely because these columns exist. A policy adapter must deliberately consume the representation, validity mask, timing and normalization; its training and evaluation must include that modality.
Our earlier CSV exercise uses separate time, unit and validity fields. It is an independent synthetic exercise with normalized values and its own schema, not a direct export of this arbitrary-unit fixture. To convert real logs, first segment episodes, choose a reference time axis, preserve capture/receive clock provenance, align observations to actions and document exclusions. ROS messages and bags provide transport and recording; they do not define the learning dataset or calibrate its values.
Optional: run the official numeric writer and loader
This second exercise uses LeRobot 0.6.1 to create, finalize and reload a local dataset in format v3.0. It generates its own complete numeric example; the limited PyArrow fixtures above are not presented as official-loader-ready exports. No Hub account, upload, GPU or robot is required. The script disables Hub access.
On Windows x64 with CPython 3.13.3 and uv 0.9.28, we checked 2 episodes, 8 frames, metadata totals, episode/frame/global indices, per-episode timestamps and the exact state, action and tactile arrays. Before the valid write, the official writer rejected a missing action and a wrong-shaped state vector. A second clean environment produced the same report using the supplied dependency lock.
This optional environment is substantially larger than the PyArrow-only checker: it installs the official package and CPU PyTorch dependencies. Tested versions are LeRobot 0.6.1, PyTorch 2.11.0+cpu, datasets 4.8.5, PyArrow 25.0.1 and NumPy 2.2.6. Video encoding/decoding, task-policy compatibility and training remain untested. The custom observation.tactile vector contains four synthetic normalized arbitrary values, with no implied policy support or physical unit.
uv venv .venv --python 3.13
uv pip install --python .\.venv\Scripts\python.exe --torch-backend cpu -r requirements-windows-py313.lock.txt
.\.venv\Scripts\python.exe verify_loader.py --output resultsPASS: LeRobot 0.6.1 wrote and loaded 8 numeric frames in 2 episodes (v3.0).Resolve common validation failures
A missing field usually means the export mapping or shard selection is wrong. Compare the actual Parquet columns with info.json; do not add fabricated zero vectors to make a check pass. A timestamp failure may be an episode segmentation error, a duplicated row or a mismatch between declared FPS and the export grid. Preserve the raw log while correcting the export.
Boundary failures require comparing inclusive starts and exclusive ends with the actual global row indices. Do not treat each shard as one episode. Dimension failures require checking the feature definition and controller mapping before padding or truncating vectors.
An unreadable Parquet file can be an incomplete download or a writer that was not closed. The official v3 guide calls for dataset.finalize() before upload to flush buffered metadata and close writers. The optional official-loader exercise above runs finalize(); the lightweight PyArrow checker does not use that API.
Complete Python script
This is the same check_dataset.py supplied in the download. It reads small local Parquet snapshots and writes a separate JSON report.
Read the complete checker (149 lines)
"""Validate a small numeric LeRobot v3-style snapshot, not full compatibility.
No videos, task-text mapping, statistics, dtype/codec conformance, or policy
compatibility checks. Read-only; all input is loaded into memory.
Copyright 2026 RoboSkin.ai. SPDX-License-Identifier: Apache-2.0
"""
from __future__ import annotations
import argparse
import json
import math
from pathlib import Path
import sys
import pyarrow.parquet as pq
SCOPE = "numeric-v3-subset; not an official LeRobot compatibility validator"
SCALARS = ("index", "episode_index", "frame_index", "task_index", "timestamp")
REQUIRED = (*SCALARS, "observation.state", "action")
TACTILE = "observation.tactile"
VALID = "observation.tactile_valid"
def finite(value):
return type(value) in (int, float) and math.isfinite(value)
def integer(value):
return type(value) is int and value >= 0
def validate(root: Path, tolerance: float = 1e-4) -> dict:
"""Return all detected errors. A pass applies only to the documented subset."""
errors = []
report = {"scope": SCOPE, "ok": False, "frames": 0, "episodes": 0,
"unknown_tactile_values": 0, "errors": errors}
if not finite(tolerance) or tolerance < 0:
errors.append("config: tolerance must be finite and non-negative")
return report
try:
info = json.loads((root / "meta/info.json").read_text(encoding="utf-8"))
if not isinstance(info, dict): raise ValueError("info.json must be an object")
features = info.get("features")
if not isinstance(features, dict): raise ValueError("features must be an object")
if info.get("codebase_version") != "v3.0":
errors.append("version: this exercise supports codebase_version v3.0 only")
fps = info.get("fps")
if not finite(fps) or fps <= 0: raise ValueError("fps must be finite and positive")
for field in REQUIRED:
if field not in features: errors.append(f"schema: missing feature {field}")
for name, spec in features.items():
if not isinstance(spec, dict): raise ValueError(f"invalid feature specification: {name}")
if spec.get("dtype") not in ("float32", "float64", "int64", "bool"):
errors.append(f"unsupported: {name} dtype {spec.get('dtype')}; numeric subset only")
shape = spec.get("shape")
if not isinstance(shape, list) or len(shape) != 1 or not integer(shape[0]) or shape[0] == 0:
errors.append(f"unsupported: {name} needs a positive one-dimensional shape")
if name in SCALARS and shape != [1]: errors.append(f"schema: {name} must have shape [1]")
if (TACTILE in features) != (VALID in features):
errors.append("schema: example tactile values and validity must be declared together")
if TACTILE in features and VALID in features and features[TACTILE].get("shape") != features[VALID].get("shape"):
errors.append("schema: tactile values and validity shapes differ")
if info.get("video_path") is not None:
errors.append("unsupported: video layout is outside this exercise")
files = sorted((root / "data").glob("chunk-*/file-*.parquet"))
ep_files = sorted((root / "meta/episodes").glob("chunk-*/file-*.parquet"))
if not files or not ep_files: raise ValueError("data and episode Parquet shards are required")
rows, locations = [], []
for file in files:
table = pq.read_table(file)
for name in set(REQUIRED) | set(features):
if name not in table.column_names: errors.append(f"fields: {file.relative_to(root).as_posix()} missing {name}")
chunk_rows = table.to_pylist()
rows.extend(chunk_rows)
locations.extend([file.resolve()] * len(chunk_rows))
episodes = [row for file in ep_files for row in pq.read_table(file).to_pylist()]
report.update(frames=len(rows), episodes=len(episodes))
if not rows or not episodes: errors.append("empty: frames and episodes must be non-empty")
for key, count in (("total_frames", len(rows)), ("total_episodes", len(episodes))):
if not integer(info.get(key)) or info[key] != count: errors.append(f"counts: {key} does not match files")
for i, row in enumerate(rows):
for key in SCALARS[:-1]:
if not integer(row.get(key)): errors.append(f"values: row {i} {key} must be a non-negative integer")
if row.get("index") != i: errors.append(f"index: row {i} must have global index {i}")
if not finite(row.get("timestamp")): errors.append(f"time: row {i} timestamp must be finite")
for name, spec in features.items():
if name in SCALARS: continue
values = row.get(name)
shape = spec.get("shape", [])
if not isinstance(values, list) or len(shape) != 1 or len(values) != shape[0]:
errors.append(f"dimension: row {i} {name} does not match {shape}")
continue
flags = row.get(VALID)
for j, value in enumerate(values):
if name == TACTILE and isinstance(flags, list) and j < len(flags) and flags[j] is False:
report["unknown_tactile_values"] += 1
continue # Unknown stays unknown; never replace it with zero.
dtype = spec.get("dtype")
good = type(value) is bool if dtype == "bool" else (type(value) is int if dtype == "int64" else finite(value))
if not good: errors.append(f"values: row {i} {name}[{j}] invalid or missing")
cursor = 0
for number, ep in enumerate(episodes):
keys = ("episode_index", "length", "dataset_from_index", "dataset_to_index", "data/chunk_index", "data/file_index")
if not all(integer(ep.get(key)) for key in keys):
errors.append(f"boundary: episode {number} missing or invalid metadata")
continue
start, stop = ep["dataset_from_index"], ep["dataset_to_index"]
if ep["episode_index"] != number or start != cursor or stop <= start or stop > len(rows) or stop-start != ep["length"]:
errors.append(f"boundary: episode {number} has a gap, overlap, count or index error")
cursor = stop
template = info.get("data_path")
if not isinstance(template, str): raise ValueError("data_path must be a string")
linked = (root / template.format(chunk_index=ep["data/chunk_index"], file_index=ep["data/file_index"])).resolve()
if not linked.is_relative_to(root.resolve()): raise ValueError("data_path escapes dataset root")
previous = None
for frame, i in enumerate(range(start, min(stop, len(rows)))):
row = rows[i]
if row.get("episode_index") != number or row.get("frame_index") != frame:
errors.append(f"boundary: row {i} episode/frame index disagrees with metadata")
if locations[i] != linked: errors.append(f"path: row {i} does not match episode data shard")
stamp = row.get("timestamp")
if finite(stamp):
if previous is not None and stamp <= previous: errors.append(f"time: episode {number} timestamp is not strictly increasing at frame {frame}")
if abs(stamp - frame/fps) > tolerance: errors.append(f"cadence: episode {number} frame {frame} differs from frame_index/fps")
previous = stamp
if cursor != len(rows): errors.append("boundary: episode metadata does not cover all rows")
except (OSError, ValueError, TypeError, KeyError, IndexError) as exc:
errors.append(f"input: {exc}")
report["ok"] = not errors
return report
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("dataset", type=Path)
parser.add_argument("--report", type=Path, required=True)
parser.add_argument("--tolerance", type=float, default=1e-4, help="seconds, default 0.0001")
args = parser.parse_args()
report = validate(args.dataset, args.tolerance)
# Reports are an explicit output; never overwrite a file inside the input.
if args.report.resolve().is_relative_to(args.dataset.resolve()):
parser.error("--report must be outside the dataset directory")
args.report.parent.mkdir(parents=True, exist_ok=True)
args.report.write_text(json.dumps(report, indent=2, allow_nan=False) + "\n", encoding="utf-8")
print(f"{'PASS' if report['ok'] else 'FAIL'} (numeric subset): {report['frames']} frames, {report['episodes']} episodes, {len(report['errors'])} errors, {report['unknown_tactile_values']} unknown tactile values")
for error in report["errors"]: print(f"- {error}")
return 0 if report["ok"] else 1
if __name__ == "__main__": sys.exit(main())
Common questions
Is this a complete LeRobot v3 validator?
No. It checks a documented numeric subset. A passing report does not certify official loader compatibility, video alignment, task metadata, statistics, training readiness or real-world validity.
Why can the timestamp go back to zero?
In the inspected writer, time is frame_index divided by FPS within each episode. An episode reset is expected; a repeated or decreasing timestamp inside the same episode is not.
Can every LeRobot policy train with these tactile fields?
No. The tactile columns and validity mask are example designs. A matching policy data adapter, representation, training procedure and evaluation are required.
Next steps
- Robotics programming learning path →
Choose a data or tactile-feedback starting point.
- Find robot and tactile datasets →
Compare fields, access conditions and original dataset licenses.
- Robot data collection and teleoperation →
Plan demonstrations, synchronization and quality review before export.
- Process tactile CSV data with Python →
Generate heatmaps and teaching contact events without hardware.