At 16:30 UTC on Monday, bzip3's Hacker News thread had reached 392 points and 111 comments. Three hours later, version 1.5.4 arrived with a more consequential detail than the compression-ratio debate: an application could pass exactly 1 MiB to bzip3's in-memory API, receive a success code, and get back a 29-byte frame that decoded to zero bytes. The 1.5.4 comparison contains the one-line correction.
The defect sits in the high-level bz3_compress() frame helper, rather than in the command-line program's normal file path. That distinction narrows the affected group to developers embedding libbz3 through its convenience API. It does not soften the failure mode. Both compression and decompression returned BZ3_OK in the reporter's reproduction, according to issue 175.
A remainder erased the last block
bz3_compress() splits an input buffer into blocks. In version 1.5.3, the loop initially assigned a full block size, then replaced the final block's size with in_size % block_size. That remainder is zero whenever the input length is an exact multiple of the selected block size. A 1 MiB input paired with a 1 MiB block therefore reached the encoder as a final block of zero bytes, even though the function had correctly calculated that one block existed. The bug report includes the minimal C reproduction and the resulting frame fields.
The corrected line derives the final size from the bytes that remain:
size = i == n_blocks - 1 ? in_size - in_offset : block_size;
That change covers a single-block buffer as well as larger exact multiples. In the report's second example, a 2 MiB input split into 1 MiB blocks produced an 88-byte frame that decoded to only 1 MiB under version 1.5.3. The first block survived; the final block was recorded with an original size of zero. The API still reported success at both ends. The merged fix uses the remaining input length instead of a modulus for that final iteration.
Bzip3 frames include a CRC32 for each block, but a checksum cannot prove that the caller's full buffer entered the encoder. In this case, the missing bytes never became part of the final block. The decoder saw framing metadata for zero original bytes and had no encoded content against which to find a mismatch. The format specification explains that the checksum belongs inside each chunk alongside its recorded original size.
A second bug rejected valid incompressible blocks
Compression can slightly expand data that has little redundancy. Bzip3 accounts for that with bz3_bound(), which gives callers and the decoder enough space for the worst permitted output. Version 1.5.3 nevertheless rejected any stored compressed block larger than the nominal uncompressed block size. In issue 174, a 1,048,576-byte block became 1,053,554 bytes and was rejected as a malformed header, even though it still fit inside the buffer the decoder had allocated.
Version 1.5.4 changes that validation limit from block_size to bz3_bound(block_size). This failure was loud: decompression returned BZ3_ERR_MALFORMED_HEADER instead of producing truncated output. It could still break a round trip on an already compressed file, image, or other incompressible input. The source comparison shows both API fixes together in src/libbz3.c.
The current command-line source calls the lower-level bz3_encode_block(), bz3_decode_block(), and their parallel variants. It does not call the two high-level frame helpers changed here. Existing .bz3 archives made with the CLI should not be treated as evidence of this particular silent-truncation bug. Applications that use the frame API are the relevant audit target.
The release is smaller than the discussion
Version 1.5.4 collects nine commits made since 1.5.3, which shipped in August 2025. Only two small edits in src/libbz3.c address the frame bugs. The tag also fixes an out-of-source CMake build, makes CMake use the same shared-library soname as the Autotools build, removes a stray placeholder from the generated pkg-config flags, documents defaults, and updates a CI action. Those changes are visible in the tag-to-tag comparison.
There is no new compression algorithm in this release. The on-disk format still identifies itself as BZ3v1, and the codec stages documented by the project are unchanged. The timing matters because the Hacker News thread largely discussed the repository as a rediscovery of bzip3, while the tagged build published later that day is the first release package containing the two 2026 frame fixes. The commits for the malformed-header and exact-multiple bugs were merged on June 14 and July 22, respectively, according to the project's commit history.
What bzip3 trades for denser archives
Bzip3 is an LGPLv3 C codec that describes itself as a spiritual successor to bzip2. Its pipeline applies Lempel-Ziv prediction and run-length encoding before a Burrows-Wheeler transform, then uses arithmetic coding with a statistical predictor. The project overview positions it near bzip2 and LZMA on compression ratio and decompression speed, rather than alongside speed-first codecs such as LZ4.
The format works in blocks ranging from 65 KiB to 511 MiB. The command-line default is 16 MiB, and the project estimates compression and decompression memory at roughly six times the chosen block size. A larger block can find repetition across more input, though the manual says the returns diminish quickly. The decompressor inherits that memory requirement from the block size chosen during compression, as described in the 1.5.4 manual.
Parallelism is explicit rather than automatic across the whole library. The high-level frame helpers are single threaded. The low-level API has parallel block functions, and the CLI exposes -j for worker threads while defaulting to one. Each parallel worker gets its own state, so increasing the job count also multiplies much of the memory demand. The public API header spells out those allocation rules.
The benchmark case is narrower than the slogan
The project's own Linux-kernel corpus gives bzip3 a useful middle position. A 1,215,221,760-byte tar file compressed to 129,023,171 bytes with bzip3 and 157,810,434 bytes with bzip2. That makes the bzip3 output about 18.2 percent smaller. LZMA reached 125,725,455 bytes, about 2.6 percent below bzip3. These are project-published results, not an independent benchmark.
The same run recorded 77.38 seconds and 89 MB for bzip3, compared with 61.58 seconds and 8 MB for bzip2. LZMA took 398.20 seconds and 675 MB. On that corpus and configuration, bzip3 bought a smaller archive than bzip2 with slower compression and roughly eleven times the memory; it came close to LZMA's size with much lower resource use. Different data, block sizes, compiler builds, and thread counts can move every part of that comparison. The repository itself warns that compiler choice has a large effect on throughput in its README.
Those tradeoffs explain the renewed developer interest without turning one benchmark into a universal ranking. Text and source archives suit the transform well, according to the project's README. Already compressed media may expand slightly, which is exactly the case that exposed the second 1.5.3 API bug. A service choosing codecs dynamically has to test both compressible and incompressible samples, along with the boundary lengths that exposed the data-loss defect.
Who needs to update
Applications linked against libbz3 should first identify which interface they call. Users of bz3_compress() and bz3_decompress() should move to 1.5.4, or use a build that contains commits b9aacb6 and 3c60c83. The library header labels these as the high-level APIs that create and read frames. Programs using only the CLI or the low-level block functions follow different code.
A successful return code should still be followed by a round-trip check before a new codec is trusted with the only copy of data. For this bug, useful regression inputs include one block, two exact blocks, a block plus one byte, and incompressible bytes whose encoded form crosses the nominal block boundary. The output length should match the original length, followed by a byte-for-byte comparison. Those cases come directly from the two reported failures and their companion decoder report.
The project's warning against relying on an unverified compressed copy is unusually blunt, and 1.5.4 shows why that advice belongs in integration code rather than only in documentation. CRC checks can catch damaged encoded blocks. Boundary tests catch mistakes in deciding which bytes became blocks in the first place.
What to watch
The nine-commit comparison contains no changes to test files, despite fixing two round-trip edge cases in the public convenience API. The next useful signal is committed regression coverage for exact block multiples and expanded incompressible blocks, followed by downstream packages shipping 1.5.4. Until then, the release deserves attention for the narrow thing it actually changes: two small bounds calculations that decide whether an in-memory frame returns every byte it was given.