zlib decompress() Drops Concatenated Streams (tested)
If two separately zlib.compress()'d byte strings get concatenated (e.g. two chunks compressed independently then joined into one blob), decompressing the combined bytes in one call looks like it should give both back. We measured what actually happens: only the first stream's data comes back, and the rest vanishes without a trace.
1. zlib.decompress() stops after the first stream
import zlib
c1 = zlib.compress(b"first stream data")
c2 = zlib.compress(b"second stream data")
combined = c1 + c2
zlib.decompress(combined)
b'first stream data'
Measured: zlib.decompress(combined) returns only b"first stream data" — "second stream data" is gone. No exception, no truncation warning, and no way to tell from the return value alone that there was more compressed data after the first stream ended.
2. The leftover bytes exist, but only decompressobj() exposes them
d = zlib.decompressobj()
out = d.decompress(combined)
out
b'first stream data'
d.unused_data
b'x\x9c+NM\xce\xcfKQ(.)JM\xccUHI,I\x04\x00B\x9a\x06\xe3'
Measured: decompressobj() produces the identical first-stream-only output as the one-shot decompress() function, but it also populates .unused_data with the exact compressed bytes of the second stream that it didn't touch. The plain zlib.decompress() function has no equivalent attribute — those bytes are simply discarded when you use it, with nothing left to recover them from.
3. Why: a zlib stream has a defined end, and decompress() stops there
Per the Python zlib docs, each zlib-compressed stream ends with a checksum trailer that marks where that stream's data stops. decompress()/decompressobj().decompress() correctly recognize that boundary and stop there — they were never designed to concatenate multiple independent streams, so anything after the first stream's end is leftover input, not part of the same message. This is a different (and much quieter) failure mode than the already-documented unconsumed_tail gotcha, which is about output buffer limits within one stream; this one is about multiple independent streams being silently merged into one input.
4. Fix: loop on unused_data to recover every concatenated stream
remaining = combined
full = b""
while remaining:
d = zlib.decompressobj()
full += d.decompress(remaining)
remaining = d.unused_data
full
b'first stream datasecond stream data'
Measured: creating a fresh decompressobj() for each pass and feeding it whatever unused_data is left over from the previous pass recovers both streams completely. This only matters if something in your pipeline concatenates independently-compressed zlib streams (gzip handles this case correctly on its own for multi-member .gz files — this gap is specific to raw zlib.compress() output, not gzip).
Frequently asked questions
Does zlib.decompress() handle multiple concatenated zlib streams?
No — we measured it silently returning only the first stream's decompressed data and discarding everything after that stream's end, with no exception or warning.
How can I tell if zlib.decompress() dropped data from a concatenated stream?
Plain zlib.decompress() gives no signal at all — we confirmed it has no attribute exposing leftover bytes. You'd need to use zlib.decompressobj() instead, whose .unused_data attribute holds exactly the undecompressed bytes left over.
Is this the same issue as zlib's unconsumed_tail gotcha?
No — we distinguished them: unconsumed_tail is about output being cut short within a single stream when max_length limits a decompressobj() call. This is about multiple independent zlib streams concatenated together, where only the first one gets decompressed at all.
How do I decompress multiple zlib streams that were concatenated together?
We confirmed looping with a fresh zlib.decompressobj() each pass, feeding it the previous pass's .unused_data, and concatenating each pass's output recovers every stream completely.
Does gzip have the same silent-drop problem with concatenated streams?
No — we measured gzip.decompress() and gzip.open() correctly decompressing concatenated gzip members (a standard multi-member .gz file) into the full combined data with no data loss, unlike raw zlib.compress() output.
Written with AI assistance. Measured on 2026-10-03 with Python 3.13.5's built-in zlib module: compressed two separate messages independently with zlib.compress(), concatenated the two compressed byte strings, then decompressed the combined bytes with zlib.decompress() and separately with zlib.decompressobj(). PlainHeaders is our extension; Python's zlib module is built in.