Upstream LeRobot has no way back from v3.0: release 0.6.1 (2026-08-03) ships one dataset converter, forward only, and the two reverse pull requests, #2109 and #2248, were closed unmerged in November 2025. You may not need one, because LeRobot 0.6.1 trains GR00T N1.7 (policy type groot) and pi0.5 (pi05) on v3.0 directly. The downgrade is for the native Isaac-GR00T trainer, which reads a LeRobot v2 flavour plus meta/modality.json, and the native openpi trainer, whose pinned lerobot commit has CODEBASE_VERSION v2.1. For those, NVIDIA ships scripts/lerobot_conversion/convert_v3_to_v2.py inside Isaac-GR00T, which needs its own Python 3.10 or 3.11 environment with lerobot pinned at git commit c75455a6. Its --root is the parent directory, a missing path becomes a Hub download, --force-conversion deletes your local copy, pre-existing name_v3.0 and name_v2.1 folders are deleted before it starts, and video paths under /root are rejected. Before you run it, check that the resolved path exists and hash and archive the v3.0 original outside the parent directory.
If you searched "lerobot v3 to v2.1", you are holding a dataset recorded or downloaded with LeRobot 0.4 or later, and a trainer that refuses it. The upstream answer is short: there is no way back. LeRobot 0.6.1, released 2026-08-03 and still the current release, writes codebase_version v3.0 and ships one dataset-version converter, convert_dataset_v21_to_v30.py, which only goes forward. Two pull requests that proposed the reverse direction, #2109 and #2248, were both closed without merge in November 2025.
That is not the whole story. NVIDIA ships a reverse converter inside the Isaac-GR00T repository, scripts/lerobot_conversion/convert_v3_to_v2.py, and has done since the GR00T N1.6 release commit of December 2025. It works, and its source has five behaviours that decide whether your original data survives the run. Every claim below comes from reading that source and the trainers' code at the commits current on the post date, not from running the tool.
The first question is whether you need the downgrade at all. For three of the six training paths below the answer is no, and converting a corpus you did not need to convert is how a lab ends up with two copies of its data and no clear master.
Do you need to downgrade at all?
The model is not what decides this. The trainer is. GR00T N1.7 and pi0.5 can both be fine-tuned on v3.0 data today, through LeRobot; the same models need v2.1 when you use the trainers their authors ship. Model choice is a separate question, covered in our comparison of OpenVLA, pi0, SmolVLA and GR00T N1. This table covers only the dataset version each trainer reads.
The first three rows are the cheap case. LeRobot 0.6.1's GR00T documentation states that it integrates GR00T N1.7 through the groot policy type, trained with lerobot-train --dataset.repo_id=... --policy.type=groot --policy.base_model_path=nvidia/GR00T-N1.7-3B. The same documentation records a breaking change: N1.5 support was removed, and the last release that keeps it is 0.5.1, whose CODEBASE_VERSION is also v3.0. The pi0.5 documentation trains --policy.type=pi05 on lerobot/libero and describes the implementation as adapted from the openpi repository. pi0.5 inside LeRobot has its own normalisation statistics requirement, but none of these three paths needs a format change.
The last three rows are where conversion is required. If you are fine-tuning with NVIDIA's reference implementation or with Physical Intelligence's own training scripts, read on.
| Trainer you plan to use | Dataset version it reads | Convert? |
|---|---|---|
LeRobot 0.6.1, --policy.type=groot (GR00T N1.7) | v3.0 | No |
LeRobot 0.6.1, --policy.type=pi05 (pi0.5) | v3.0 | No |
| LeRobot 0.5.1, kept for GR00T N1.5 checkpoints | v3.0 | No |
Isaac-GR00T native trainer (gr00t/experiment/launch_finetune.py) | LeRobot v2 flavour plus meta/modality.json | Yes, then add modality.json |
| openpi native trainer | v2.1, through lerobot pinned at git commit 0cf86487 | Yes, then compute norm stats |
| Anything pinned to lerobot 0.3.3 or older | v2.1 | Yes |
Why the native trainers still read v2.1
NVIDIA states the reason in its data preparation guide: GR00T uses the LeRobot v2 format "because many upstream datasets (DROID, LIBERO, Bridge, etc.) are published in v2", and native support for both v2 and v3 is planned for a future release. On the post date that release had not happened. The same guide's first instruction for a v3.0 corpus is to run convert_v3_to_v2.py.
openpi's reason is a dependency pin. At commit 215abfb2 (2026-08-24), openpi's pyproject.toml sources lerobot from git at 0cf864870cf2..., a May 2025 commit whose lerobot/common/datasets/lerobot_dataset.py sets CODEBASE_VERSION = "v2.1" and whose utils.py beside it sets the v2.1 path templates: per-episode parquet under data/chunk-{episode_chunk:03d}/, plus meta/episodes.jsonl, meta/episodes_stats.jsonl and meta/tasks.jsonl. An open issue from December 2025 asks openpi to move to v3.0; it had not moved by the post date.
What each one does when you hand it v3.0
The failures do not say "wrong format", which is why this costs time. The layout difference is the root of all three. v3.0 consolidates many episodes into shared parquet and mp4 files and keeps episode metadata in parquet under meta/episodes/; v2.1 keeps one parquet file and one mp4 per camera per episode, with JSONL metadata. Our LeRobot vs RLDS vs HDF5 comparison walks through both layouts, and the forward ladder from v2.0 lives in the post on the v2.0 to v3.0 conversion path. This post covers only the way back.
- Isaac-GR00T native loader. An open pull request against the repository (#677, from May 2026) describes the current behaviour: the loader dies with a bare
FileNotFoundErroronmeta/episodes.jsonl, a file v3.0 no longer has. - openpi's pinned lerobot. Its version check compares v3.0 against v2.1 and does not raise, because a higher major version is not treated as an error; the minor-version mismatch only logs a warning. Loading then fails to open
meta/tasks.jsonl, the loader catches that and falls back to the Hub to fetch metadata at a v2.1 revision. You see a network or revision error from a dataset that never lived on the Hub. - And the reverse. LeRobot 0.6.1 handed a v2.1 dataset raises
BackwardCompatibilityError, with a message that says v3.0 "is not backward compatible with v2.1" and prints the forward converter command. That matters after conversion, as the safe-run section shows.
Setting up NVIDIA's convert_v3_to_v2.py in its own environment
The converter cannot share an interpreter with LeRobot 0.6.1. Its pyproject.toml declares requires-python = ">=3.10,<3.12"; LeRobot 0.6.1 declares >=3.12. No Python satisfies both. openpi declares >=3.11 with its own 2025 lerobot pin, so a lab doing this ends up with three environments: LeRobot 0.6.1 for recording and LeRobot-native training, the converter's, and the native trainer's.
The converter's dependencies are short: huggingface_hub, jsonlines, numpy, pyarrow, tqdm, and lerobot @ git+https://github.com/huggingface/lerobot.git@c75455a6de5c818fa1bb69fb2d92423e86c70475. That commit is the lerobot 0.4.1 version bump from 2025-10-23, with CODEBASE_VERSION v3.0. The script's docstring adds two version notes: it needs lerobot at least after commit f55c6e89f (the "Dataset v3" merge of September 2025) and was tested on 0.4.0 (commit f25ac02). Do not swap the pin for a newer lerobot to save an environment: the script imports internal helpers from lerobot.datasets.utils and was tested against 0.4.0, so anything newer is untested territory.
Install from the converter's directory, not the repository root. The converter README says so explicitly: installing from the root installs the gr00t package instead.
cd Isaac-GR00T/scripts/lerobot_conversion uv venv --python 3.11 source .venv/bin/activate # the converter README notes lerobot may need LFS smudging disabled GIT_LFS_SKIP_SMUDGE=1 uv pip install -e . --verbose which ffmpeg # the script shells out to ffmpeg for every video segment
The GIT_LFS_SKIP_SMUDGE=1 line carries the README's own hedge: you "may need" it when lerobot is installed from git. openpi's README is firmer about the same variable for its own install. The which ffmpeg line is not optional for any dataset with cameras. Video splitting shells out to the ffmpeg binary, and a missing binary stops the run with RuntimeError: ffmpeg executable not found after the parquet work is already done.
Five behaviours to know before you point it at your only copy
The CLI has three arguments: --repo-id (required), --root (default none) and --force-conversion. There is no --output, no --dry-run and no --keep-original. Everything that can hurt you follows from how those three interact with the filesystem.
The parent directory. LeRobot's forward converter takes --root as the dataset folder itself. This one appends the repo id. NVIDIA's own SO-100 example confirms the semantics: it passes --root examples/SO100/finish_sandwich_lerobot and then copies modality.json into <root>/<repo_id>/meta/. With no --root, the base is HF_LEROBOT_HOME, which at the pinned lerobot defaults to $HF_HOME/lerobot.
The silent download. The local branch runs only if the resolved path exists. A typo in --root, or a --repo-id that differs from the folder name by one character, takes the other branch and logs "Downloading dataset snapshot from the Hub". On an air-gapped host that is a network error. On a host with egress and a public dataset under the same repo id, it converts somebody else's data. The version check, validate_local_dataset_version, which raises unless info.json says exactly v3.0, runs only on the local branch.
The forced re-download. The help text for --force-conversion reads "Ignore any existing local snapshot and re-download it from the Hub". The implementation is shutil.rmtree on the resolved path. For a dataset that only ever existed on your disk, that is deletion.
The sibling folders. The docstring says the original ends up in a folder with the suffix _v30. The code builds the suffix from the constant "v3.0", so the folders are literally pick_place_v3.0 and pick_place_v2.1. Before building anything, it deletes both if they exist. A second run on a re-staged copy therefore removes the backup from the first run. If a run fails midway, the original is untouched and a partial name_v2.1 is left behind, to be deleted by the next attempt. An open pull request from July 2026 proposes making the script non-destructive by default; on the post date it had not merged.
The /root rejection. Before each ffmpeg call the script resolves both paths and rejects anything under /etc, /sys, /proc, /dev, /boot or /root. A container running as root with HF_HOME and HF_LEROBOT_HOME unset puts the default dataset at /root/.cache/huggingface/lerobot/<repo_id>, so the first video segment fails with ValueError: Path points to system directory and the run stops. info.json, tasks.jsonl and per-episode parquet are written first, which makes the failure look late and partial. A pull request opened 2026-09-02 proposes removing /root from the list and was still open on the post date.
What it rewrites
Reading the whole script, the conversion is a reshaping, not a re-derivation: It does not write meta/modality.json and adds no annotation columns. The output is plain LeRobot v2.1.
meta/info.json:codebase_versionbecomesv2.1,data_pathandvideo_pathreturn to the per-episode templates,data_files_size_in_mbandvideo_files_size_in_mbare dropped, per-featurefpsis removed from non-video features,total_chunksbecomesceil(total_episodes / chunks_size)andtotal_videosbecomes episodes times video keys.- Data: each consolidated parquet file is sliced into one file per episode using the
dataset_from_indexanddataset_to_indexcolumns. - Video: each concatenated mp4 is cut per episode with
ffmpeg -ss <start> -i <src> -t <duration> -c copy, a stream copy, using the episode'sfrom_timestampandto_timestamp. - Metadata:
meta/tasks.jsonl,meta/episodes.jsonl(withlengthcomputed when missing) andmeta/episodes_stats.jsonlare rebuilt from the v3.0 parquet metadata;meta/stats.jsonand animages/directory are copied if present.
| Flag or condition | What the code does | What to do |
|---|---|---|
--root | Resolves Path(root) / repo_id: --root is the parent of the dataset folder | Pass the parent; make --repo-id match the folder path under it |
| Resolved path does not exist | Calls snapshot_download(repo_id, repo_type="dataset", local_dir=root) with no error | Test the path yourself before running |
--force-conversion | Deletes the dataset at the resolved path, then downloads from the Hub | Never pass it against local-only data |
name_v3.0 or name_v2.1 already exists | Deletes both first, builds v2.1 in name_v2.1, moves the original to name_v3.0, moves v2.1 into name | Refuse to run if either exists; archive elsewhere |
Video source or destination under /root | Rejects it as a system directory, after info.json, tasks.jsonl and per-episode parquet are already written | Keep data outside /root, or run as a non-root user |
A safe run: pre-flight, convert, verify
The pre-flight script below fails loudly before anything is deleted. It computes the exact path the converter will resolve, refuses if that path does not exist, confirms the version, refuses if either sibling folder exists, and archives a hashed copy outside the parent directory. Run it from scripts/lerobot_conversion with the converter's environment active. HF_HUB_OFFLINE=1 on the last line is belt and braces after the path check, not the primary guard: the converter leaves the huggingface_hub version unpinned.
#!/usr/bin/env bash
# run from Isaac-GR00T/scripts/lerobot_conversion with its .venv active
ROOT=/data/lerobot # PARENT directory, passed as --root
REPO_ID=lab/pick_place # must match the folder name under ROOT
DS="$ROOT/$REPO_ID"
test -f "$DS/meta/info.json" || { echo "no dataset at $DS: the script would download from the Hub"; exit 1; }
python3 -c 'import json,sys; v=json.load(open(sys.argv[1]))["codebase_version"]; print(v); sys.exit(v!="v3.0")' "$DS/meta/info.json" || exit 1
for s in _v3.0 _v2.1; do test -e "$DS$s" && { echo "$DS$s exists and will be deleted"; exit 1; }; done
# archive the original outside ROOT before anything rewrites it
(cd "$DS" && find . -type f -print0 | sort -z | xargs -0 sha256sum) > /archive/pick_place_master.sha256
cp -a "$DS" /archive/pick_place_master
HF_HUB_OFFLINE=1 python convert_v3_to_v2.py --repo-id "$REPO_ID" --root "$ROOT"There is no --force-conversion anywhere in it, and /data/lerobot keeps the work outside /root. If you run the converter as root in a container, mount the dataset volume somewhere else and pass that parent as --root.
After a successful run, /data/lerobot/lab/pick_place is v2.1 and LeRobot 0.6.1 can no longer load it: point LeRobot-side work at /archive/pick_place_master instead, or at pick_place_v3.0 until you clean up. Curation belongs on the v3.0 master as well. If you need to remove bad episodes from a LeRobot dataset, do it there and derive a fresh v2.1 copy, rather than editing the derived one. The same goes for merges, whose own failure modes are covered in what breaks when you merge LeRobot datasets.
Then verify against the archived original, not against the output's own metadata:
import json, pathlib
import pyarrow.parquet as pq
new = pathlib.Path("/data/lerobot/lab/pick_place")
old = json.load(open("/archive/pick_place_master/meta/info.json"))
info = json.load(open(new / "meta/info.json"))
assert info["codebase_version"] == "v2.1"
assert info["fps"] == old["fps"]
assert info["total_episodes"] == old["total_episodes"]
eps = [json.loads(l) for l in open(new / "meta/episodes.jsonl")]
assert len(eps) == info["total_episodes"]
for ep in eps:
i = ep["episode_index"]
f = new / info["data_path"].format(episode_chunk=i // info.get("chunks_size", 1000), episode_index=i)
assert pq.read_metadata(f).num_rows == ep["length"], (i, f)
video_keys = [k for k, ft in info["features"].items() if ft.get("dtype") == "video"]
mp4s = list((new / "videos").rglob("*.mp4"))
assert len(mp4s) == info["total_videos"] == len(eps) * len(video_keys)
print("ok", len(eps), "episodes", len(mp4s), "videos")Each assertion has a job. The version and fps checks catch a run against the wrong folder. The episode-count check compares the new metadata with the archived original, which catches a Hub download that replaced your data. The per-episode row count checks the parquet slicing against the length field the converter wrote. The mp4 count checks that the video step finished for every camera. The chunk fallback of 1,000 matches the converter's default chunk size at the pinned lerobot. pyarrow is already a converter dependency, so this runs in the same environment.
What it does not check is frame alignment inside each mp4. The video step is a stream copy, not a re-encode, so if your policy is sensitive to it, decode each episode's video and compare frame counts against episodes.jsonl before you book GPU time.
After conversion: modality.json for GR00T, norm stats for openpi
The converter's output is LeRobot v2.1. Neither native trainer is ready for it yet.
Isaac-GR00T
NVIDIA's format is "a flavor of the LeRobot v2 dataset format with an additional meta/modality.json file". Write or copy modality.json into the converted dataset's meta/ folder, as NVIDIA's SO-100 example does. What the state and action slices in it should mean for your robot is a modelling decision, covered in our guide to joint versus end-effector action spaces. The data preparation guide adds a second requirement the converter does not touch: language annotations in a parquet column named annotation.<source>.<type>, such as annotation.human.task_description, holding an index into meta/tasks.jsonl. The guide says that although task_index may still serve the default annotation, the dedicated column "is required to ensure it is loadable by our custom data loader", and that the column, the modality.json annotation key and the data config must agree. NVIDIA's own SO-100 example shows only conversion plus copying modality.json; check your modality.json annotation keys against the columns your parquet files actually contain before launching. Fine-tuning then goes through gr00t/experiment/launch_finetune.py with --dataset-path, --embodiment-tag and --modality-config-path.
openpi
openpi's training loader constructs LeRobotDatasetMetadata(repo_id) and LeRobotDataset(repo_id, delta_timestamps=...) with no root argument, so it reads HF_LEROBOT_HOME/<repo_id>. If you converted without --root, the dataset is already there. If you used --root /data/lerobot, set HF_LEROBOT_HOME=/data/lerobot in the openpi environment. Get this wrong and the pinned loader takes its Hub fallback rather than failing on the path. Then run scripts/compute_norm_stats.py for your config before training. openpi computes its own normalisation statistics; its README lists missing norm stats as a training error to fix this way. Treat the converted dataset as a new dataset for this step, even though meta/stats.json was copied across.
On a segmented network: v3.0 is the master, v2.1 is derived
Four of the five behaviours in the table above are network failures or data-custody failures. On a lab or plant network with restricted egress, that changes how the conversion is run.
Stage the converter environment offline. Clone Isaac-GR00T, fetch lerobot at commit c75455a6 and the converter's wheels on a connected build host, and carry them across as an archive with their own hashes, the same way you would stage any other pinned tool. Our audit of the Hub calls LeRobot makes and how to run it air-gapped covers the general pattern; the two calls specific to this post are the converter's snapshot_download on a missing path and openpi's metadata fallback on a missing file. Both trigger on a local mistake, not on a deliberate download, which is why the path check runs before the converter and not after.
Treat the v3.0 corpus as the system of record. In a regulated setting, whether a pharma lab validating a dispensing cell or a defence supplier qualifying an inspection robot, a fine-tuned checkpoint has to be traceable to the exact episodes it learned from. The converter's in-place rewrite works against that: after one run, the original lives in a sibling folder the script itself deletes on the next run. Keep the master outside the converter's parent directory, hashed, and derive v2.1 copies from it. For every derived copy, record a provenance row: the v3.0 manifest hash, the Isaac-GR00T commit the converter came from (the file is unchanged since the July 2026 N1.7 general release), the lerobot commit c75455a6, the v2.1 manifest hash, and the trainer that consumed it. When the trainer moves to v3.0, the derived copy is deleted and the master is untouched.
This is the part of robot data work we build for clients on their own infrastructure: pinned conversion environments archived beside the corpus, a master that never gets rewritten, and derived training copies that carry their lineage. More on the cluster lives on the physical AI pillar page.
This week, pick one v3.0 dataset you expect to train with a native trainer. Run the pre-flight checks on it without the final line, confirm the resolved path and version, and write its provenance row with the master's hash before any conversion touches it.
FAQ
Quick answers to the questions this post tends to raise.



