ROS 2 bag record split: –max-bag-size vs –max-bag-duration

한국어로 읽기

What happened when I ran it

If you record cameras and IMUs for hours with ros2 bag record, a single bag file can grow to tens of gigabytes. The usual fix is to split it with -b (--max-bag-size, in bytes) and -d (--max-bag-duration, in seconds). The rosbag2 README says that when both are set, “the bag will split at whichever threshold is reached first”. It doesn’t tell you which one that will be for your recording. That depends on your data rate, not on the option values alone.

To check that, I scaled the problem down so it would run in seconds. In a ROS 2 Jazzy container I used the same -b 8000000 -d 6 for two synthetic topics. One sent 200,000-byte messages at 20 Hz, about 4 MB/s. The other sent 1,000-byte messages at 50 Hz, about 50 kB/s. Before recording, a /tf_static transform was published once. The script ran five times.

The fast topic was split by size every 2.0 seconds, and the 6-second limit never came into play. The slow topic was split by time just after 6 seconds, and its files were less than 5% of the 8 MB limit. Both results matched a one-line calculation made before the runs. Two other things came up. The first file was longer than the others in both cases, because /tf_static arrived first and started that file’s clock. And /tf_static was only in the first file in all five runs. Jazzy has no option to write it again into later files.

The short answer

Let R be the total data rate of every topic you record, in bytes per second. Let B be your -b value in bytes and D your -d value in seconds.

T_size = B / R      # seconds until a file reaches -b

T_size < D  ->  size splits first, roughly every T_size seconds
T_size > D  ->  duration splits first, roughly every D seconds
files per hour  ~  3600 / split interval

It is an OR, not an AND. One limit is enough to close the file. If you want 60-second files from a high-rate recording, changing -d does nothing. You have to raise -b above R × 60 so that size never wins.

# Option syntax checked against the Jazzy CLI definition; the article's runs used -b 8000000 -d 6
ros2 bag record -a -b 900000000 -d 60

Before you trust the numbers, keep three things in mind:

  • Files closed by the size limit are always a bit larger than -b, never smaller. On Jazzy and Lyrical they can overshoot by up to the write cache size (100 MiB by default).
  • The time limit is measured using message receive timestamps, not a wall clock, and it is only checked when a message arrives.
  • Latched (transient local) topics such as /tf_static and /map end up only in the file that was open when they were received. --repeat-transient-local fixes this, but only on Lyrical and Rolling.

Measured results

Environment. Image ros:jazzy-ros-base (ros@sha256:066420e07f60aa18262f2479981def87ebcfcec42eefb0c0c57c4a46098348ca), ROS 2 Jazzy, Python 3.12.3, rclpy 7.1.12-1noble.20260902.053513, RMW rmw_fastrtps_cpp (default), no network, limits 1 CPU and 1 GiB, /tmp on tmpfs. Storage mcap, default write cache (no --max-cache-size), no compression. The script experiment.py (sha256 abacd2ff211426a51c72c6e4008f3cd2fa475840dd0d43a0b5fa2e07bbbf5f93) ran 5 times. All 5 runs exited with code 0 and took 28.31 s to 28.42 s.

Setup. One rclpy node published /tf_static (base_link to camera_link) once with tf2_ros.StaticTransformBroadcaster. Then, for each scenario, it started a real recorder as a subprocess: ros2 bag record -o /tmp/bag_high -s mcap -b 8000000 -d 6 /high/data /tf_static (and the same with low). It waited until the recorder had subscribed, waited 0.5 s more, published std_msgs/UInt8MultiArray messages on a monotonic-clock schedule, and stopped the recorder with SIGINT (the same as Ctrl+C). Each split file was then opened on its own with rosbag2_py.SequentialReader. The predictions below were written in plan.md before the five runs.

Check Predicted (plan.md) Measured (5 runs)
high: 200000 B payload × 20 Hz, 9 s serialized about 200,016 B, R ≈ 4,000,320 B/s, T_size ≈ 2.0 s < 6 s, size splits first serialized 200016 B, 181 published, 20.11 Hz achieved, R 4022306 to 4022336 B/s, T_size 1.989 s; 4 closed files, all 4 split by size, 0 by duration
high: split interval and file count about every 2 s; 4 closed files + 1 last file 5 files, 4 closed; interval of files 1 to 3: 2.0 s; file 0: 2.487 to 2.495 s
high: closed file size vs -b at least -b; excess about one message (about 200,016 B) plus storage overhead 8017304 to 8018939 B, so 17304 to 18939 B over -b; 40 data messages (8000640 B of payload) per closed file; byte-identical in all 5 runs
low: 1000 B payload × 50 Hz, 14 s serialized about 1,016 B, R ≈ 50,800 B/s, T_size ≈ 157.5 s > 6 s, duration splits first serialized 1016 B, 701 published, 50.07 Hz achieved, R 50871 to 50872 B/s, T_size 157.259 s; 2 closed files, both split by duration, 0 by size
low: split interval and file count just after 6 s, at the first message (up to 20 ms late); 2 closed files + 1 last file 3 files, 2 closed; file 0: 6.002 to 6.018 s; file 1: 6.0 to 6.02 s; “interval > D” true for every closed file
low: messages per closed file — file 0: 273 to 276; file 1: 300 to 301; closed file sizes 305290 to 333418 B
Time from file 0 start to its first data message — high 0.488 to 0.496 s, low 0.494 to 0.542 s; 0.0 s in every later file
Lost messages — none: recorded = published (181 and 701) in every run
Files containing /tf_static only the first file (1 message), 0 in the others [0] in both scenarios, all 5 runs
Minimum -b, via SequentialWriter.open() mcap: 1023 rejected, 1024 accepted; sqlite3: 86015 rejected, 86016 accepted exactly that, all 5 runs
Jazzy ros2 bag record --help no --repeat-transient-local, no --max-bag-files both absent; --max-bag-size, --max-bag-duration and the cache default present

Raw lines from run 1 (stdout, one JSON object per line):

{"check": "min_split_size", "storage": "mcap", "max_bag_size": 1023, "accepted": false, "error": "Invalid bag splitting size given. Please provide a value greater than 1024. Specified value of 1023"}
{"check": "jazzy_cli_options", "has_max_bag_size": true, "has_max_bag_duration": true, "has_repeat_transient_local": false, "has_max_bag_files": false, "max_cache_size_default_in_help": true}
{"scenario": "high", "event": "file", "index": 1, "closed_by_split": true, "size_bytes": 8017304, "size_minus_B": 17304, "data_payload_bytes": 8000640, "data_msgs": 40, "tf_static_msgs": 0, "start_offset_s": 2.491, "first_data_after_file_start_s": 0.0, "meta_duration_s": 1.95, "split_interval_s": 2.0, "size_reached_B": true, "interval_exceeded_D": false}
{"scenario": "high", "event": "summary", "files": 5, "closed_files": 4, "mean_split_interval_s": 2.123, "min_split_interval_s": 2.0, "max_split_interval_s": 2.491, "closed_split_by_size": 4, "closed_split_by_duration": 0, "observed_first": "size", "predicted_first": "size", "max_size_minus_B": 18939, "recorded_data_msgs": 181, "published": 181, "files_with_tf_static": [0]}
{"scenario": "low", "event": "file", "index": 0, "closed_by_split": true, "size_bytes": 306353, "size_minus_B": -7693647, "data_payload_bytes": 278384, "data_msgs": 274, "tf_static_msgs": 1, "start_offset_s": 0.0, "first_data_after_file_start_s": 0.533, "meta_duration_s": 5.993, "split_interval_s": 6.013, "size_reached_B": false, "interval_exceeded_D": true}
{"scenario": "low", "event": "summary", "files": 3, "closed_files": 2, "mean_split_interval_s": 6.006, "min_split_interval_s": 6.0, "max_split_interval_s": 6.013, "closed_split_by_size": 0, "closed_split_by_duration": 2, "observed_first": "duration", "predicted_first": "duration", "max_size_minus_B": -7667645, "recorded_data_msgs": 701, "published": 701, "files_with_tf_static": [0]}

The last file of each scenario was closed by SIGINT, not by a limit, so it is left out of the split and overshoot checks. Its sizes (4216614 to 4216615 B for high) are in results.json.

Every prediction held. That alone isn’t surprising, since the formula is simple. What the runs add is confirmation that the real recorder, with its message-by-message checks, ends up where the arithmetic says. They also show how far off it can be, which the next section covers.

Why the measured intervals differ slightly from the formula

“Cut the video every N megabytes or every N seconds” is a good picture for the OR rule. Where it breaks down is that rosbag2 never splits a message in half, and it only checks the limits when a message arrives. On every write, should_split_bagfile() is called. If it returns true, the current file is closed and the incoming message goes into the new file (write() in sequential_writer.cpp). The size check is >= and the time check is >, joined with OR (Jazzy, Rolling).

Size: a closed file is always at least -b

The file is closed when the next message arrives after the size is already at or above -b. So the excess is less than one message plus format overhead. In the high scenario the largest excess was about 9.5% of one 200,016-byte message, or about 0.24% of -b. The gap between file size and summed payload is most likely MCAP record headers and the index. I didn’t break it down.

The same rounding explains why the interval was 2.0 s and not the calculated 1.989 s. At 20 Hz a message arrives every 50 ms, so the size limit can only be crossed on a 50 ms boundary. Expect the real interval to be off from T_size by up to one message period.

Duration: receive timestamps, and only when a message arrives

The time check compares the current message’s receive timestamp with the first timestamp in the current file. Because the check is “greater than” and runs on arrival, the split happens on the first message after -d has passed. At 50 Hz that is within 20 ms, which matches the 6.0 to 6.02 s intervals. Intervals printed as 6.0 are rounded to three decimals. The check recorded “more than 6 s” for every closed file. And 300 messages × 20 ms = 6 s is why the files held 300 or 301 messages.

One consequence follows from the source but wasn’t measured: if every recorded topic goes quiet, nothing gets split, even with -d 60. When messages start again, the first one triggers a single split. With --use-sim-time the timestamps come from /clock instead. That wasn’t tested here.

The first file: the first message of any topic starts the clock

In the first trial run, the first split interval was longer than the rest in both scenarios. I added a per-file “file start to first data message” field. The second trial showed that the first file’s start time was the receive time of /tf_static, which got there about half a second before the data. The split logic itself was not changed.

The effect depends on which limit is active. With size splitting (high), the first file still held the same 40 messages and only took about 0.5 s longer. With duration splitting (low), the clock was already running, so the first file covered 6 s of wall time but only about 5.5 s of data: 273 to 276 messages instead of 300. On a real robot the same thing happens whenever a latched topic or a slow topic is received before your sensors. The first file’s sensor data is shorter than -d by that delay.

Working it out for your own topics

Get R with ros2 topic bw <topic> for each topic you record, then add the values together. -b limits the whole storage file, not each topic. bw sums the serialized length of the messages it receives over a window (100 messages by default). Its units are decimal: KB is 1,000 bytes and MB is 1,000,000 bytes (bw.py units, bw.py calculation). -b takes an integer number of bytes. Many guides use 2147483648 for “2 GB”, but that is 2 GiB (2³¹ bytes). 1 GiB is 1,073,741,824 bytes and 1 GB is 1,000,000,000 bytes. Don’t mix the two in the formula.

Here is the same -b 900000000 -d 60 applied to two made-up recordings. Message size and rate are constant, and compression, format overhead and caching are ignored:

Recording (assumed) R T_size = 900,000,000 / R Splits first Files per hour
Camera, 1,000,000 B × 30 Hz 30,000,000 B/s 30 s size (30 s < 60 s) 3600 / 30 = 120
IMU + tf 200,000 B/s 4,500 s duration (4,500 s > 60 s) 3600 / 60 = 60

For the camera, -d 60 never fires. To get 60-second files you need -b above 30,000,000 × 60 = 1,800,000,000 bytes. For the IMU recording, each 60-second file is about 200,000 × 60 = 12,000,000 bytes, and -b never fires.

A few quick checks:

  1. R = 5,000,000 B/s, -b 900000000 -d 60. T_size = 180 s > 60 s, so duration wins, about 60 files per hour.
  2. The 30 MB/s camera should produce 60-second files. Raise -b above 1,800,000,000. If you also need a hard size cap on Jazzy or Lyrical, account for the cache (see below).
  3. Jazzy, two-hour bag, you open only the third file. Is /tf_static there? Not unless something published it again during that file.
  4. -d 60, and every topic is silent for 10 minutes. How many new files? None. The check only runs when a message arrives. This follows from the source and wasn’t measured.

Only the first file has /tf_static

/tf_static is published with transient local durability. The publisher keeps its last message and delivers it to subscribers that join later. That is how the recorder got the transform even though it started after the broadcast. But a bag only contains messages the recorder actually received. Since /tf_static was not published again, it was only in file 0 in every run. If you replay or analyze file 1 on its own, the base_link to camera_link transform is missing. The README describes this case: “consumers of the new file may need certain transient-local (latched) messages — such as /map or /tf_static — to be present, even if they were not published again during the new file’s timespan” (README, Rolling).

Lyrical and Rolling have --repeat-transient-local <topic>[=<depth>] (depth 1 if omitted) and --repeat-all-transient-local [<depth>]. When a new file is opened, the recorder writes the last N cached messages of those topics at the start of it (Lyrical record.py, PR #2386). The Jazzy help output had neither option, and the Humble and Kilted branches don’t have them either.

# Lyrical / Rolling only. Syntax from the README and CLI definition; not run in this test.
ros2 bag record -a -b 900000000 -d 60 --repeat-transient-local /tf_static

The repeated copies get the timestamp of the message right before the split, not the original publish time (write_transient_local_messages). They also count toward the new file’s size. The extra space is roughly files × Σ(depth × message size). With 120 files per hour and an assumed 5,000-byte /tf_static, that is 600,000 bytes per hour, which is negligible. A /map occupancy grid can be much larger, so do the same calculation for it. If you use a QoS override file, the override takes precedence, so it must keep transient local durability as well.

On Humble, Jazzy and Kilted I found no option that does this. The only thing I can say for certain is that the recorder won’t do it for you.

The minimum -b: 86016 is for sqlite3, MCAP’s is 1024

The README says -b “must be greater than 86016” without naming a storage format. That number is the minimum file size of the sqlite3 plugin (sqlite_storage.cpp). MCAP, the default since Jazzy (default_storage_id.cpp, Jazzy), has a minimum of 1024 (mcap_storage.cpp, Jazzy). The writer throws when -b is below the plugin’s minimum (check in sequential_writer.cpp). So the minimum itself is accepted, even though the error message says “greater than”. The container runs above confirm both boundaries.

In practice you won’t hit this limit: a 30 MB/s recording fills 86,016 bytes in under 3 ms. The more useful habit is to check which storage format your bag uses before copying numbers from the README.

Why a Jazzy file can be far larger than -b

The write cache (--max-cache-size, default 100 MiB = 104,857,600 bytes) buffers messages in memory before they reach disk. On Jazzy and Lyrical, the split check only looks at what has already been written to storage. The source has a TODO about adding the cached size. When a split happens, the remaining cache is flushed into the old file (Jazzy cache flush on split). Issue #2310 reports files “significantly larger than --max-bag-size“. PR #2496 says “a file can overshoot by up to the cache size” and fixes it by adding the cache size to the check. That fix was merged into Rolling only, on 2026-10-01. As of 2026-10-14 the Lyrical branch still has the old check.

If you plug the default cache into that bound, a Jazzy file recorded with -b 900000000 could reach 900,000,000 + 104,857,600 = 1,004,857,600 bytes, about 11.7% over. That is an upper bound worked out from the PR text, not a measurement. The runs above used tmpfs, where writes are fast enough that the cache barely builds up. Slow disks were not tested. If you need a hard limit, for example FAT32’s 4 GiB or an upload cap, set -b at least one cache size below it on Jazzy and Lyrical. Setting --max-cache-size 0 writes directly instead. The CLI help recommends about one second’s worth of cache, so this may cause write stalls at high data rates. I didn’t measure that either.

What differs by distribution

The Jazzy column’s minimum and option checks come from the container runs. Everything else comes from each branch’s source and the issues and PRs above, as of 2026-10-14. Packaged binaries can lag behind the branch, so run ros2 bag record --help on your own machine.

Humble Jazzy Kilted Lyrical Rolling
Default storage sqlite3 mcap mcap mcap mcap
Minimum -b for the default storage 86016 1024 1024 1024 1024
--repeat-transient-local no no no yes yes
Cache counted in the size check not checked no no (listed as affected in #2310) no yes (since 2026-10-01)

Lyrical and Rolling also have --max-bag-files, which deletes the oldest split file to keep a fixed number (ring-buffer recording). It needs a size or duration limit to be set. Jazzy’s CLI doesn’t have it.

A checklist before a long recording

  1. Measure each topic with ros2 topic bw and add them up. MB means 10⁶ bytes.
  2. Compare T_size = B / R with D. Decide which limit you want to control the split, and set the other one well out of the way.
  3. On Jazzy or Lyrical, if you need a hard size cap, lower -b by at least the cache size.
  4. Record for a short while and compare ros2 bag info with your calculation.
  5. Run ros2 bag record --help. If --repeat-transient-local is missing, assume later files won’t contain your static transforms and plan your analysis around that.

Recording doesn’t move the robot, but a full disk on the robot’s computer can make the recorder and other processes fail. Stop a recording with Ctrl+C in its terminal. The test used the same signal, and the recorder exited cleanly every time.

Limits of this test

  • Synthetic topics with a constant rate, all inside one container. With a real camera the data rate changes, so T_size changes too. With compression on, files fill more slowly for the same R.
  • Only Jazzy, only MCAP, no compression, and /tmp on tmpfs. The cache overshoot on slow disks was not measured. Lyrical and Rolling behaviour (repeat option, cache fix) comes from reading the source only.
  • MCAP buffers data in chunks, so the size seen by the split check may lag behind what has been written. That is my reading of the code and was not verified separately.
  • The “no messages, no split” behaviour and --use-sim-time come from the source. Neither was tested.

Sources and reproduction

The full reproduction is at github.com/sungpyo9053/blog/…/ros2-bag-record-split-size-duration-whichever-first. It contains experiment.py, the predictions and trial-run notes in plan.md (in Korean), and the raw output of all five runs in results.json. To approximate the test container:

docker run --rm --network none --cpus 1 --memory 1g --tmpfs /tmp -v "$PWD":/work \
  ros@sha256:066420e07f60aa18262f2479981def87ebcfcec42eefb0c0c57c4a46098348ca \
  bash -c "source /opt/ros/jazzy/setup.bash && python3 /work/experiment.py"

Your first-file timings will differ by a few tens of milliseconds, because they depend on when the recorder subscribes. To judge the result, look at the two summary lines. observed_first should equal predicted_first, files_with_tf_static should be [0], and recorded_data_msgs should equal published.