A LeRobot corpus whose meta/info.json declares codebase_version v2.0 has no single-step route to v3.0. Release v0.4.0 (2025-10-23) moved CODEBASE_VERSION to v3.0 and dropped src/lerobot/datasets/v21/ in the same tag, so the release that made v2.0 a hard load failure is also the release that removed the tool that fixed it. On v0.6.1 a v2.0 dataset raises NotImplementedError pointing at Discord, because the BackwardCompatibilityError constructor only formats the helpful message when the minor version is 1. The path is two rungs: v2.0 to v2.1 on lerobot 0.3.3, then v2.1 to v3.0 on 0.6.1. They need two environments, because requires-python moves from 3.10 to 3.12 and the datasets pins are disjoint at <=3.6.0 against >=4.8.0. Rung one has exactly three flags, no --root, and four Hub calls that no flag disables, so inside a segmented network it has to be reimplemented at the library level. In the 2,000 most-downloaded LeRobot-filtered Hub datasets, 73 rows still declare v2.0 and carry 1,892,774 episodes between them. Run the glob audit over your own capture root this week and write the declared codebase_version into your provenance row.
Converting a LeRobot dataset from v2.0 to v3.0 is not one command. It is two commands from two different releases of the same package, run in two Python environments that cannot coexist, and the first of the two cannot finish, as shipped, without reaching the Hugging Face Hub.
That is the acknowledged state, not a bug report. Upstream issue #2858, opened and closed as completed on 2026-01-27, asked whether a script converts a LeRobot dataset directly from v2.0 to v3.0. The reply on the thread was that no single-step script exists and that the supported route runs v2.0 to v2.1 on a pre-0.4.x release first. The mechanics behind that answer are the part worth reading. Release v0.4.0, published 2025-10-23, moved CODEBASE_VERSION to v3.0 and dropped the entire src/lerobot/datasets/v21/ directory in the same tag. One release turned a v2.0 corpus into a hard load failure and removed the tool that fixed it.
The rows this affects are not obscure. In an audit of the 2,000 most-downloaded LeRobot-filtered datasets on the Hub, 73 rows still declare codebase_version v2.0, and between them those rows declare 1,892,774 episodes and 116,972,809 frames. This post is about that tier only, and about what the ladder costs when the corpus lives inside your own network. The v3.0 on-disk layout is a different subject, covered in our robot data format comparison.
If your dataset says v2.1, stop here: one command, and the flag that keeps it local
A v2.1 corpus is a solved case, and the error tells you so. When check_version_compatibility rejects a v2.1 dataset on lerobot 0.6.1, the message it formats carries the working command:
We introduced a new format since v3.0 which is not backward compatible with v2.1.
Please, update your dataset to the new format using this command:
python -m lerobot.scripts.convert_dataset_v21_to_v30 --repo-id={repo_id}That script ships at src/lerobot/scripts/convert_dataset_v21_to_v30.py in v0.6.1 at 20,587 bytes. Its module docstring documents a local form under the heading "Convert a local dataset (works in place)", and its argparse block declares --root and --push-to-hub among seven flags. Point it at the exact folder holding meta/, data/ and videos/, and turn the push off:
python -m lerobot.scripts.convert_dataset_v21_to_v30 \
--repo-id=your-org/kitting-2025 \
--root=/data/captures/kitting-2025 \
--push-to-hub=falseThat is the whole v2.1 story. Everything from here concerns corpora whose meta/info.json says v2.0.
The error a v2.0 dataset actually raises, and why it names no command
Load a v2.0 corpus on lerobot 0.6.1 and you get no command at all. You get a link to a chat server.
The path through the code is short. LeRobotDatasetMetadata._load_metadata calls load_info(self.root) and then check_version_compatibility(self.repo_id, self._version, CODEBASE_VERSION). At line 332 of src/lerobot/datasets/utils.py, the guard is if v_check.major < v_current.major and enforce_breaking_major:, with enforce_breaking_major defaulting to True. For a v2.0 corpus against CODEBASE_VERSION v3.0, that is 2 against 3, so it raises. The interesting part is what happens inside the exception being constructed. BackwardCompatibilityError.__init__ formats the helpful message only when version.major == 2 and version.minor == 1. A v2.0 corpus has minor 0, so control reaches the else branch, which raises NotImplementedError at line 73 before the BackwardCompatibilityError object exists.
The two raise sites and the exception that reaches your terminal, with the frame filenames shortened for print:
File "lerobot/datasets/utils.py", line 333, in check_version_compatibility
raise BackwardCompatibilityError(repo_id, v_check)
File "lerobot/datasets/utils.py", line 73, in __init__
raise NotImplementedError(
NotImplementedError: Contact the maintainer on [Discord](https://discord.com/invite/s3KuuzsPFb).Two details in that trace decide how you debug it. The exception class is NotImplementedError, not BackwardCompatibilityError, so a handler or a log filter written against the compatibility class will never see it. And the call site in dataset_metadata.py wraps its metadata load in a try that catches FileNotFoundError and NotADirectoryError only, so nothing intercepts it on the way up.
Where the failure happens matters too. load_info reads meta/info.json off the local root before the version check runs, so this is a purely local rejection: an air-gapped machine holding a complete v2.0 corpus fails exactly the same way as a laptop with a live connection.
None of this was true a release earlier. At v0.3.3, CODEBASE_VERSION is v2.1, so a v2.0 corpus differs only in the minor version, check_version_compatibility emits a logging.warning instead of raising, and the dataset loads. The warning text even names the converter. So the same corpus that is unreadable on 0.6.1 opens on 0.3.3 with a nudge.
Reading codebase_version out of meta/info.json across your own corpus
Before any of this matters you need to know which tier each capture is on, and that is a stdlib job. INFO_PATH is the string meta/info.json at v0.3.3 and still meta/info.json at v0.6.1, so one glob covers every release in the ladder. Do not import lerobot to answer it: the import pulls in the version gate you are surveying around.
import collections, json, pathlib
ROOT = pathlib.Path("/data/captures")
counts = collections.Counter()
for info_path in sorted(ROOT.rglob("meta/info.json")):
info = json.loads(info_path.read_text())
declared = info.get("codebase_version", "NO codebase_version FIELD")
counts[declared] += 1
print(f"{declared:>8} {info.get('total_episodes', '-'):>8} {info_path.parent.parent}")
print(counts)Two schema hazards make this a string comparison rather than a version parse, and both were found in real published corpora. The first is that the declared value is not always a version. One row in the Hub sample declares v2.0-embeddings-renamed-renamed-sharded, which packaging.version.parse cannot read at all. Print what the file says and compare it to the literals v2.0, v2.1 and v3.0.
The second is that older v2.0 metadata predates the features block. Two v2.0 rows in the sample, aliberts/pusht_image and aliberts/aloha_sim_insertion_human, carry keys, shapes, names and image_keys instead, and one has no total_frames field either. That is why total_episodes is fetched with a default above. What to do with captures that old belongs alongside deciding which episodes to cut, not inside a migration.
Where the v2.0 to v2.1 rung went: present at v0.3.3, gone by v0.4.0
The removal is visible in two git trees read at their tags. The v0.3.3 tree returns 506 paths with truncated: false and contains src/lerobot/datasets/v21/convert_dataset_v20_to_v21.py at 3,697 bytes, src/lerobot/datasets/v21/batch_convert_dataset_v20_to_v21.py at 1,826 bytes and src/lerobot/datasets/v21/convert_stats.py at 4,209 bytes. It also still carries the rung below, src/lerobot/datasets/v2/convert_dataset_v1_to_v2.py at 25,622 bytes. The v0.4.0 tree returns 629 paths and contains nothing under src/lerobot/datasets/v21/ or src/lerobot/datasets/v2/ at all. Its only dataset-version converter is src/lerobot/datasets/v30/convert_dataset_v21_to_v30.py at 19,570 bytes.
The v0.5.0 and v0.5.1 rows are there for one reason: the v2.1 converter changed directory between them, from src/lerobot/datasets/v30/ to src/lerobot/scripts/. A 404 on the older path is a file move, not a deletion, and the shipped exception prints the current path either way.
The row that carries the argument is v0.4.0. It sets CODEBASE_VERSION to v3.0, it already contains the NotImplementedError branch in the exception constructor, and it removes the converter, in one release. What it does not contain is a reason: the removal is confirmed by comparing two trees, the change that performed it was not identified here, so treat it as what happened rather than why.
Those two rungs are the entire supported path, and the asymmetry in the fourth column is the part that decides your architecture.
| Release | Date | CODEBASE_VERSION | v2.0 to v2.1 tool in the tree | v2.1 to v3.0 tool in the tree | What a v2.0 dataset does |
|---|---|---|---|---|---|
| v0.3.3 | 2025-08-06 | v2.1 | datasets/v21/convert_dataset_v20_to_v21.py | none | Loads, with a warning |
| v0.4.0 | 2025-10-23 | v3.0 | none | datasets/v30/convert_dataset_v21_to_v30.py | NotImplementedError |
| v0.5.0 | 2026-03-09 | v3.0 | none | datasets/v30/ | NotImplementedError |
| v0.5.1 | 2026-04-07 | v3.0 | none | scripts/ | NotImplementedError |
| v0.6.1 | 2026-08-03 | v3.0 | none | scripts/convert_dataset_v21_to_v30.py | NotImplementedError |
| Step | lerobot release | Local root flag | Hub reachability required as shipped | What it rewrites |
|---|---|---|---|---|
| v2.0 to v2.1 | 0.3.3 | none | Yes | Per-episode stats into meta/episodes_stats.jsonl, plus codebase_version |
| v2.1 to v3.0 | 0.6.1 | --root | No, with --push-to-hub=false | The on-disk layout plus the metadata |
How much of the public catalogue is still v2.0, against the 2,000-row sample we already published
We already published a 2,000-row audit of the LeRobot-format catalogue on the licence axis, in what Open X-Embodiment actually licenses. The same sample, extended on the codebase_version axis only, gives the split below. The method is the LeRobot-filtered listing sorted by downloads and capped at 2,000 rows, then one anonymous fetch of meta/info.json per row.
import collections, json, urllib.request
from huggingface_hub import HfApi
api = HfApi()
ids = [d.id for d in api.list_datasets(filter="LeRobot", sort="downloads", limit=2000)]
counts = collections.Counter()
for repo_id in ids:
url = f"https://huggingface.co/datasets/{repo_id}/resolve/main/meta/info.json"
try:
with urllib.request.urlopen(url, timeout=25) as response:
counts[json.load(response).get("codebase_version", "NO FIELD")] += 1
except Exception:
counts["NO READABLE info.json"] += 1
print(counts)That is the 2,000 most-downloaded LeRobot-filtered rows, not the catalogue, and the shares are shares of the sample. The unreadable bucket splits into 241 rows returning HTTP 401, a gated or private repo seen anonymously, and 24 returning HTTP 404 for meta/info.json at main. Over the 1,735 rows that did return a readable file, the split is 867 v2.1 (49.97 percent), 794 v3.0 (45.76 percent), 73 v2.0 (4.21 percent) and 1 unparseable.
The share understates the exposure. Weighting the same sample by the download counts the listing endpoint returns, those 73 v2.0 rows account for close to half of all downloads across the 2,000, and five of the seven most-downloaded rows declare v2.0. IPEC-COMMUNITY/language_table_lerobot is the most-downloaded row in the sample, at v2.0 with 442,226 episodes; IPEC-COMMUNITY/droid_lerobot declares v2.0 with 92,233 episodes and 27,044,326 frames; physical-intelligence/libero declares v2.0 with 1,693 episodes. Downloads move on a rolling window, so read that concentration as a shape rather than a figure. The shape is the point: small by row count, large by usage. Nor is the tier one exotic capture rig. The robot_type values across those 73 rows run panda at 24, so100 at 7, franka at 5, widowx at 5 and aloha at 5.
Declared codebase_version | Rows | Share of the 2,000 | What it means for you |
|---|---|---|---|
v2.1 | 867 | 43.35% | One command, --root and --push-to-hub=false |
v3.0 | 794 | 39.70% | Current, loads on 0.6.1 |
No readable info.json | 265 | 13.25% | Unknown to an anonymous client |
v2.0 | 73 | 3.65% | The two-release ladder |
| A string that is not a version | 1 | 0.05% | Read it, do not parse it |
Step one cannot run offline: the Hub calls the old converter makes
This is where a private perimeter changes the answer, and it changes it for one rung and not the other.
The second rung is fine inside a network boundary. Passing --root skips the initial Hub probe for an existing v3.0 revision outright, because that probe sits behind if root is None and not force_conversion:, and every remaining Hub call in convert_dataset sits under if push_to_hub:. One caveat before you trust the flag: if --root points at a path that does not exist, the local branch is never taken and the script calls snapshot_download into that root instead, so a typo turns an offline conversion into a download.
The first rung is a different animal, and not by accident of packaging. Its argparse block at v0.3.3 declares exactly three flags: --repo-id (required), --branch (default None) and --num-workers (default 4). There is no --root and no --push-to-hub. Its docstring says it converts "any LeRobot dataset already pushed to the hub" and lists one usage form:
python -m lerobot.datasets.v21.convert_dataset_v20_to_v21 \
--repo-id=aliberts/koch_tutorialThe body constructs LeRobotDataset(repo_id, revision=V20, force_cache_sync=True), and force_cache_sync at v0.3.3 is implemented as a deliberate raise FileNotFoundError inside a try, which skips the local metadata load and forces get_safe_version (a Hub refs listing) followed by snapshot_download. After the conversion it makes four more Hub calls: push_to_hub with allow_patterns="meta/", an HfApi().file_exists, an HfApi().delete_file and an HfApi().create_tag. No flag disables any of them. The shipped v2.0 to v2.1 entrypoint cannot complete inside a segmented network, full stop.
The underlying work, though, is entirely local. LeRobotDataset at v0.3.3 accepts root=, and with force_cache_sync at its default False and the files present it takes the local branch and makes no Hub call. So the recommendation for an air-gapped corpus is not "pin an old release and run the command", it is "pin an old release and call its functions", in the order the shipped script calls them, with the push removed. Three deliberate differences from that script: the root is passed, force_cache_sync stays False, and all four Hub calls are omitted. The sequence below was assembled by reading the v0.3.3 source rather than executed here, so treat it as the shape to implement and verify against your own corpus.
# lerobot 0.3.3 environment, no Hub reachability required
from lerobot.datasets.lerobot_dataset import CODEBASE_VERSION, LeRobotDataset
from lerobot.datasets.utils import EPISODES_STATS_PATH, STATS_PATH, load_stats, write_info
from lerobot.datasets.v21.convert_stats import check_aggregate_stats, convert_stats
dataset = LeRobotDataset("your-org/kitting-2025", root="/data/captures/kitting-2025")
if (dataset.root / EPISODES_STATS_PATH).is_file():
(dataset.root / EPISODES_STATS_PATH).unlink()
convert_stats(dataset, num_workers=4)
ref_stats = load_stats(dataset.root)
check_aggregate_stats(dataset, ref_stats)
dataset.meta.info["codebase_version"] = CODEBASE_VERSION # "v2.1" at 0.3.3
write_info(dataset.meta.info, dataset.root)
(dataset.root / STATS_PATH).unlink()The order matters and it is the shipped order: convert_stats computes the new per-episode statistics first, and only then is stats.json read for the comparison. One quirk of the original is worth carrying knowingly. Line 77 of the shipped converter reads if (dataset.root / STATS_PATH).is_file: with no call parentheses, so the branch is always truthy and the unlink is unconditional in practice. On a v2.0 corpus that is harmless, because the check_aggregate_stats call above it cannot pass without it, which is why the snippet above calls unlink directly.
Budget for that third row. A v2.0 meta/ directory is small: physical-intelligence/libero lists four files, the largest being episodes.jsonl at 180,384 bytes. Per-episode statistics at scale are not. On the v2.1 corpus cadene/droid_1.0.1, meta/episodes_stats.jsonl is 965,863,054 bytes, roughly 966 MB of metadata. One corpus is not a rule, but it is the order of magnitude to plan against at DROID scale, and it lands before the second rung runs.
The governance side of running LeRobot operations on local roots with no Hub egress is a separate argument we made in full when a dataset merge broke in exactly that setting. This section is the narrower point: for the first rung it is not a preference, it is a reimplementation.
| File | v2.0 | v2.1 | What step one does to it |
|---|---|---|---|
meta/info.json | present | present | Rewrites codebase_version to v2.1 |
meta/stats.json | present | absent | Deletes it |
meta/episodes_stats.jsonl | absent | present | Writes one line per episode |
meta/episodes.jsonl and meta/tasks.jsonl | present | present | Untouched |
The pinned old-release appliance, and archiving a converter as a build artifact
Downgrading is the obvious move when the tool you need lives in an old release. It does not work here, because the two rungs cannot share an environment.
requires-python is >=3.10 at 0.3.3 and >=3.12 at 0.6.1. More decisively, the datasets pins are disjoint: 0.3.3 requires datasets>=2.19.0,<=3.6.0 as a core dependency, while the dataset extra that the 0.6.1 converter needs requires datasets>=4.8.0,<5.0.0. The torch ranges differ too, >=2.2.1,<2.8.0 against >=2.7,<2.12.0. No resolver satisfies both. So the first rung is a second environment: a separate interpreter, a separate virtualenv, and a separate lockfile.
# archive the converter beside the corpus
pip download "lerobot==0.3.3" --no-deps -d /srv/artifacts/lerobot
# a separate interpreter, because the two releases cannot share one environment
python3.12 -m venv /opt/lerobot-0.3.3
/opt/lerobot-0.3.3/bin/pip install "lerobot==0.3.3"
# the old converter has no --root, so the only path knob is the environment
HF_LEROBOT_HOME=/data/lerobot-home \
/opt/lerobot-0.3.3/bin/python -m lerobot.datasets.v21.convert_dataset_v20_to_v21 \
--repo-id=your-org/kitting-2025Two caveats on that block, both load-bearing. It was not executed here, so the resolution of the 0.3.3 dependency set on a current interpreter is not something to take on trust: run it once, capture the resolved lockfile, and archive that lockfile rather than the version string. And the final invocation still requires Hub reachability, because the module does. Inside a perimeter, pair the same pinned environment with the library-level sequence from the previous section.
HF_LEROBOT_HOME is the only path knob the old converter exposes. It is read from the environment at 0.3.3 and defaults to HF_HOME with lerobot appended, so if you do not set it the corpus lands wherever HF_HOME points.
The retention argument follows from the dates. PyPI serves lerobot 0.3.3 as a 597,692-byte wheel, uploaded 2025-08-06, not yanked. That is one package version published in August 2025, and it is the newest artifact that carries the readable path for a v2.0 capture. If a corpus is expected to outlive the project that recorded it, the converter is not a build-time dependency to resolve on demand. It is an archived artifact belonging in the same store as the corpus, next to the lockfile that resolves it and a note saying which interpreter produced that lockfile.
What to verify on a converted dataset before you book a GPU
Four checks, specific to this ladder rather than generic dataset hygiene.
One: the statistics equivalence gate. The 0.3.3 step runs this for you. check_aggregate_stats compares the aggregate of the new per-episode statistics against the old stats.json with np.testing.assert_allclose, at rtol 1e-2 and atol 1e-2 for video features and rtol 5e-6 and atol 6e-5 for everything else. Those tolerances separate a conversion that preserved your normalisation constants from one that quietly did not. If you reimplemented the rung to run offline, this call is the acceptance test the original shipped with, and it is not optional. Normalisation drift is one route to a fine-tune that lands at zero percent success.
Two: the fps coercion. convert_info in the second rung executes int() on the fps field, so a capture declaring 3.75 frames per second lands at 3 with no warning. Rare and real: exactly one of the 1,735 readable rows in the Hub sample declares a non-integer fps, IPEC-COMMUNITY/dobbe_lerobot at 3.75, itself a v2.0 corpus. Assert the field across the boundary.
import json, pathlib
root = pathlib.Path("/data/captures/kitting-2025")
old = root.parent / f"{root.name}_old"
new_info = json.loads((root / "meta/info.json").read_text())
old_info = json.loads((old / "meta/info.json").read_text())
assert new_info["codebase_version"] == "v3.0"
assert new_info["fps"] == old_info["fps"], (old_info["fps"], new_info["fps"])
assert new_info["total_episodes"] == old_info["total_episodes"]
assert new_info["total_frames"] == old_info["total_frames"]Those last two equalities hold on a clean conversion because convert_info carries total_episodes, total_frames and total_tasks through untouched. Do not add assertions on total_chunks or total_videos: the converter deletes both by design.
Three: the missing-key crash, checked before you start. convert_info executes del info["total_chunks"] and del info["total_videos"] on the raw dictionary with no guard, so an info.json lacking either key raises KeyError on that key name. Two v2.0 rows in the sample carry neither: aliberts/pusht_image and aliberts/aloha_sim_insertion_human. The first rung does not add them, so a corpus missing them at v2.0 is still missing them at v2.1. Check first:
import json, pathlib
info_path = pathlib.Path("/data/captures/kitting-2025/meta/info.json")
info = json.loads(info_path.read_text())
for required in ("total_chunks", "total_videos"):
if required not in info:
print(f"missing {required}: convert_info will raise KeyError")Feed a v2.0 corpus straight into the second rung and you get a different refusal. validate_local_dataset_version runs whenever the resolved root already exists on disk, and produces a clear ValueError rather than the Discord link:
ValueError: Local dataset has codebase version 'v2.0', expected 'v2.1'. This script is specifically for converting v2.1 datasets to v3.0.
That is the script working as designed. Read it as confirmation that you are on the two-rung ladder, not as something to work around with --force-conversion.
Four: the rollback surface. The docstring calls the second rung an in-place conversion. It is not, in the disk-budget sense. convert_dataset writes a complete tree at the sibling path <name>_v30, moves the original to <name>_old, then moves the new tree into the original path. Budget roughly twice the corpus size in the parent directory, and treat _old as both your rollback and your comparison baseline. It is also a trap: if <name>_old and <name> both exist when the script starts, it removes <name> and moves <name>_old back over it, discarding a completed conversion. Rename or archive _old before you re-run anything.
One thing not to expect from upstream documentation. Issue #3583, "Docs: add migration guide for lerobot v2 to v3", was created 2026-05-12 and closed as completed on 2026-08-05. The migration guide in the tree, docs/source/porting_datasets_v3.mdx, has shipped since v0.4.0 and is 10,012 bytes at v0.6.1, and its only migration section is headed "Migrating from Dataset v2.1". The string v2.0 does not appear in the file. Nor is there a way back: two pull requests proposing v3.0 to v2.1 conversion, #2109 and #2248, are both closed unmerged.
The version gate is enforced by the dataset loader, not by any policy. Everything read here goes through LeRobotDatasetMetadata, which calls check_version_compatibility before anything else touches your data, so which VLA you are training is downstream of this. That choice is a separate comparison, in OpenVLA against pi0, SmolVLA and GR00T N1, and none of those options changes the tier your corpus is on.
For anyone holding robot data with a multi-year horizon, a format version is a retention property rather than a build detail. A v2.0 capture mirrored into internal storage is readable today only through a package version published in August 2025 or earlier, resolvable only into a Python environment the current release cannot share, and reachable, as shipped, only through an entrypoint that needs a network the corpus may never be allowed to touch. None of those three facts appear in a dataset card. All three follow from one string in meta/info.json. More of this reasoning sits in the physical AI pillar, and where a corpus came from in the first place is covered in our comparison of teleoperation, simulation and human video.
The small thing to do this week: run the glob audit over your capture root, and write the declared codebase_version into the provenance row you already keep for each capture. It takes a few minutes and it turns a future migration from an archaeology project into a filter.
FAQ
Quick answers to the questions this post tends to raise.



