MCAP is a container format for timestamped, schema-tagged messages, built for exactly the problem a robot creates: many independent streams — camera frames, joint states, force readings, discrete events — arriving at different rates and needing to land in one file that a human or a training pipeline can open months later. It comes out of Foxglove's work on ROS tooling, and it has since become the default recording format for ROS 2 itself.
What's actually in the file
An MCAP file is a sequence of records. Three kinds matter for understanding the format:
- Schema records describe a message type — a ROS 2
.msgdefinition, a Protobuf descriptor, a JSON Schema document, a FlatBuffers schema — and are embedded once per type, in the file itself. - Channel records bind a topic name to a schema, the way a ROS topic binds a name to a message type.
- Message records hold the actual timestamped payload, referencing a channel.
Writers can emit messages directly, or — for anything beyond a trivial recording — group them into chunk records: blocks of messages written roughly in arrival order, each independently compressible and independently indexable. At the end of the file sits a summary section with per-channel message indexes and a chunk index, so a reader can jump straight to the byte range containing, say, /joint_states between two timestamps without scanning the rest of the file.
The combination is what "self-describing" means in practice: open an .mcap file cold, with no external schema registry and no sourced workspace, and you can still enumerate every channel, decode every message, and seek to any point in the timeline.
- Structure
- Append-only log of Schema / Channel / Message records, optionally grouped into Chunks
- Indexing
- Per-chunk message index + trailing summary index for O(1) seek to a topic/time range
- Compression
- zstd or lz4, applied per chunk so seeking survives compression
- Schema encodings
- ros1msg, ros2msg, protobuf, jsonschema, flatbuffer, or raw bytes
Its relationship to ROS 2
MCAP is not ROS-specific, but it grew up alongside ROS 2's recording tooling. rosbag2_storage_mcap shipped as an optional storage plugin, then became the default storage plugin for ros2 bag record starting with ROS 2 Iron Irwini (2023) — the sqlite3-backed .db3 format remains fully supported and is still the right call in a few narrow cases (see MCAP vs ROS 2 bag for the detailed comparison). ros2 bag play auto-detects which storage backend a file uses, so switching the write side doesn't break existing playback tooling.
The practical effect: on any ROS 2 distro from Iron onward, ros2 bag record -a writes MCAP unless you pass --storage sqlite3.
Recording and reading
# Record every topic to MCAP (default on Iron and newer)
ros2 bag record -a -o pick_place_014
# Convert a ROS 1 .bag or an old sqlite3 bag
mcap convert session.bag session.mcapfrom mcap.reader import make_reader
with open("pick_place_014.mcap", "rb") as f:
reader = make_reader(f)
for schema, channel, message in reader.iter_messages(topics=["/joint_states"]):
print(message.log_time, channel.topic, len(message.data))For decoded ROS 2 messages instead of raw bytes, swap in mcap_ros2.reader.read_ros2_messages, which resolves the embedded schema and returns typed objects.
Writing MCAP directly, without ROS
Not every data-collection rig runs ROS. A custom teleoperation stack, a standalone camera-and-force-sensor logger, or a non-ROS simulator can write MCAP directly with the mcap Python package's Writer class — the same schema/channel/message model described above, built up record by record instead of arriving from ros2 bag record:
import json
from mcap.writer import Writer
with open("pick_place_014.mcap", "wb") as f:
writer = Writer(f)
writer.start()
schema_id = writer.register_schema(
name="JointState",
encoding="jsonschema",
data=json.dumps({
"type": "object",
"properties": {"positions": {"type": "array", "items": {"type": "number"}}},
}).encode("utf-8"),
)
channel_id = writer.register_channel(
topic="/joint_states",
message_encoding="json",
schema_id=schema_id,
)
for t, positions in enumerate(recorded_positions):
payload = json.dumps({"positions": positions}).encode("utf-8")
writer.add_message(
channel_id=channel_id,
log_time=t * 10_000_000, # nanoseconds
data=payload,
publish_time=t * 10_000_000,
)
writer.finish()register_schema and register_channel each run once per message type and topic; add_message runs per message and references the IDs they return. writer.finish() writes the trailing summary section — the chunk index and per-channel message index that make the file seekable — so a file a writer crashed on before calling finish() still opens and reads (append-only, per the earlier note about interrupted recordings), just without the fast-seek index a cleanly closed file gets. The JSON Schema payload above is illustrative; a Protobuf or FlatBuffers schema works the same way, registered once as bytes and referenced by ID from every message on that channel.
The reader ecosystem
Because the spec is open and the wire format is simple to parse, native reader and writer libraries exist in Python, C++, Go, Rust, TypeScript, and Swift, all validated against the same conformance suite. That breadth is what makes MCAP useful outside a single lab's stack: a file recorded by a robot's ROS 2 node can be visualized in Foxglove with zero conversion, loaded into a Python training pipeline with pip install mcap, or streamed into a browser-based tool over HTTP range requests using the same index that makes local seeking fast.
Where MCAP fits in a data pipeline
MCAP is a capture-and-archive format, not a training-batch format. It excels at exactly-once, lossless recording of everything a robot produced, in the order it produced it, readable by a debugger years later. It is not what a PyTorch dataloader wants to shuffle-and-batch from directly — that's the job of a training format like LeRobot or RLDS, generated from the MCAP recording rather than recorded in place of it.
A pipeline that treats MCAP as the source of truth and converts on demand for each downstream consumer avoids the failure mode where a training-framework-specific format changes shape and the raw capture is unrecoverable. That's the pattern used across teleoperation capture: one MCAP per episode, with training exports generated from it rather than replacing it.
Use MCAP when you need lossless, indexed, multi-language-readable capture of heterogeneous timestamped streams — which, for most robot data collection, is the default answer rather than a special case.