Skip to content
← All articles

zip(strict=True) and the silent-truncation bug

zip() stops at the shortest argument and says nothing. That is a data-corruption bug no test with equal-length fixtures will ever find, and the fix has been one keyword argument since 3.10.

>>> names = ["alice", "bob", "carol"]
>>> scores = [91.0, 88.5]
>>> dict(zip(names, scores))
{'alice': 91.0, 'bob': 88.5}

No exception. No warning. No log line. Carol is simply not in the report, and the report looks completely normal.

This is the failure mode worth naming: not a crash, not wrong arithmetic, but a smaller correct-looking answer. Every value present is right. The absence is the bug, and absence is what nobody reviews.

Why the tests do not catch it

Because your fixtures are the same length. They are the same length because you wrote them at the same time, in the same file, by hand. The mismatch arrives in production, from an upstream job that dropped a row, or a CSV with a trailing newline, or a paginated API where the second call returned one fewer record than the first.

The probability that a length mismatch appears in a hand-written fixture is roughly zero, and the probability that it appears in real input over a year is roughly one. That asymmetry is the whole reason this bug survives good test suites.

strict=True

PEP 618 added the strict keyword in 3.10:

>>> list(zip([1, 2, 3], [1, 2], strict=True))
ValueError: zip() argument 2 is shorter than argument 1

>>> list(zip([1, 2], [1, 2, 3], strict=True))
ValueError: zip() argument 2 is longer than argument 1

Two messages, one per direction, and both name which argument is at fault by position.

Make it your default. Any time you zip two things that are supposed to correspond — names and scores, ids and embeddings, columns and values, keys and a parsed row — write strict=True. Reserve the bare zip for the cases where truncation is genuinely the intent, and leave a comment saying so, because the reader cannot tell the difference between “deliberately truncating” and “forgot the keyword”.

💡strict=True raises "at the point of the mismatch". What exactly does that mean for a zip you only partially consume? click to reveal

It means the check is as lazy as zip itself, and it can therefore never fire.

>>> pairs = zip([1, 2, 3], [1, 2], strict=True)
>>> next(pairs), next(pairs)
((1, 1), (2, 2))          # no error, ever, unless you keep going

zip pulls one item from each iterable per next(). The mismatch is only discovered when one iterable raises StopIteration and another does not — which is on the third next() here. Stop before then and strict=True has done precisely nothing.

Two practical consequences. First, strict=True protects loops and full materialisations (list(...), dict(...), a for that runs to the end) and does not protect next(zip(a, b, strict=True)) or a zip fed into islice. Second, and more subtly: if either argument is a generator with side effects, the strict check consumes one extra item from the longer one to discover the mismatch. That is unavoidable — you cannot know a stream is longer without pulling from it — but it is worth knowing before you assume “it raised, so nothing was consumed”.

When the arguments are Sequences, comparing len() up front is both eager and cheaper. strict=True is the tool for when you do not have lengths.

Wrap it in a domain error

ValueError: zip() argument 2 is shorter than argument 1 is accurate and unhelpful at 3am. It does not say what the arguments are, and it does not say by how much — which is the first thing you want, because “off by one” and “off by four thousand” are different incidents.

class LengthMismatch(ValueError):
    pass


def align(names: Sequence[str], scores: Sequence[float]) -> dict[str, float]:
    try:
        pairs = list(zip(names, scores, strict=True))
    except ValueError as exc:
        raise LengthMismatch(
            f"length mismatch: names={len(names)}, scores={len(scores)}"
        ) from exc
    return dict(pairs)

Three things are doing work there.

The domain name. LengthMismatch is catchable by callers who know what to do about it, which ValueError is not — every third-party library raises ValueError for something.

Both lengths, always. Not “names is longer”, not “3 vs 2 arguments” — the two actual numbers, labelled with the two actual names from your domain.

from exc. The __cause__ keeps zip’s own message in the traceback, so the reader sees the domain framing and the mechanical cause, in that order. Dropping the from clause is a small crime that costs someone twenty minutes later.

The Sequence in that signature is deliberate

len() appears in the error message, so the parameter type must be something with a length. Iterable[str] and then len() is a type error, and the reflex fix — call list() first — quietly reintroduces the materialisation this whole track is about avoiding.

Pick the ABC that matches what the body actually does. If your inputs really are streams and you have no lengths, then you cannot produce that error message, and the honest version is to report the position at which the mismatch was found instead:

def align_streaming(names: Iterable[str], scores: Iterable[float]) -> dict[str, float]:
    out: dict[str, float] = {}
    try:
        for name, score in zip(names, scores, strict=True):
            out[name] = score
    except ValueError as exc:
        raise LengthMismatch(f"streams diverged after {len(out)} pairs") from exc
    return out

The neighbours

itertools.zip_longest is the other half of the choice. It pads the short one with fillvalue:

>>> list(itertools.zip_longest([1, 2, 3], ["a"], fillvalue=None))
[(1, 'a'), (2, None), (3, None)]

This is right when the shortfall is meaningful — an optional column, a series with missing tail values. It is wrong as a way to make an error go away, because None then flows downstream as data and surfaces somewhere less informative.

A quick decision table:

Situation Use
Two columns that must correspond zip(a, b, strict=True)
Deliberately taking the first N of a longer stream zip(a, b) plus a comment saying so
Missing values are meaningful itertools.zip_longest(a, b, fillvalue=...)
You have lengths and want eager failure compare len() before zipping

Two smaller notes. map has no strict parameter, so map(f, a, b) truncates silently with no opt-out — if the lengths must match, zip strictly first and map over the pairs. And zip with three or more arguments reports only the first pair it finds disagreeing, so a three-way mismatch will name one argument and stay quiet about the rest; if you need all of them, check lengths yourself.

💡Every zip in a legacy codebase is bare. You cannot audit them all this week. Which ones do you fix first? click to reveal

Rank by blast radius, not by count.

First: anything whose result becomes a mapping or a persisted record. dict(zip(keys, values)) is the highest-severity form, because the truncation is invisible in the output — a dict with three entries looks exactly like a dict that was supposed to have three entries. Same for zip feeding a database insert, a CSV writer, or a serialised payload. The corruption outlives the process.

Second: anything where the two arguments come from different sources. zip(rows_from_db, rows_from_api) can drift; zip(items, range(len(items))) cannot. A zip whose arguments are derived from the same object is nearly always safe, and that observation prunes most of the list quickly.

Third: anything inside a loop that aggregates. A truncation that silently reduces a sum or a count produces a plausible number, and plausible numbers are the ones nobody questions.

What you can deprioritise: zips over two literals in the same function, zips where truncation is structurally impossible, and zips in test code. And if you want mechanical help, ruff’s B905 (zip-without-explicit-strict) flags every bare zip — turn it on in warn-only mode first, read the list, and you will have your ranking in an afternoon.