zip() Silent Truncation Trap: The Parallel Lists That Fall Out of Step2026-08-16
This function sends a reminder email to every active user, pairing each user with a personalized subject and body loaded from a separate table. It ran cleanly for months. Then a customer complained they'd received someone else's promotional email — with their own real name printed at the top.
def send_reminders(session):
users = (session.query(User)
.filter_by(active=True)
.order_by(User.id)
.all())
prefs = (session.query(EmailPref)
.order_by(EmailPref.user_id)
.all())
sent = 0
for user, pref in zip(users, prefs):
send_email(
to=user.email,
subject=pref.subject.format(name=user.first_name),
body=pref.body.format(name=user.first_name),
)
sent += 1
log.info("Sent %d reminders to %d active users", sent, len(users))
return sent
The two queries have different filters. users is restricted to active=True; prefs is not filtered at all. Both are ordered by user id, and the developer assumed the rows line up positionally — users[0] matches prefs[0], and so on. They almost never do.
The moment a single active user has no EmailPref row, or an inactive user has one, the sequences drift out of step. From that point on, every user receives another user's template — with their own name splashed in by .format(name=...), which makes the mistake look like a legitimate personalized email rather than a garbled string. The bug slips past code review because the two lists look like they should agree.
Worse, zip() silently truncates to the shorter iterable. If there are 5,000 active users but only 4,800 preference rows, the last 200 users are dropped without a trace. The log line proudly reports Sent 4800 reminders to 5000 active users — a factually correct sentence that conceals a data-integrity failure. No exception, no warning, no test trips.
The trap is that zip() was designed for cases where truncation is the point (zip(range(10), infinite_stream)). When you're pairing parallel data pulled from two sources, silent truncation is exactly the wrong default.
Two changes. First, use zip(..., strict=True) (Python 3.10+) so mismatched lengths raise ValueError. Second — and more importantly — stop relying on positional alignment across two independent queries. Join on the key:
def send_reminders(session):
rows = (session.query(User, EmailPref)
.join(EmailPref, EmailPref.user_id == User.id)
.filter_by(active=True)
.all())
for user, pref in rows:
send_email(
to=user.email,
subject=pref.subject.format(name=user.first_name),
body=pref.body.format(name=user.first_name),
)
log.info("Sent %d reminders", len(rows))
return len(rows)
The join makes the correspondence explicit: each row pairs a user with their own preference, or is dropped from the result set. Users without a preference row are visibly absent — you can then decide to LEFT JOIN with a default template, or log the gap. Either way, the pairing is no longer a positional coincidence waiting to break.
Any time you find yourself calling zip on two sequences that came from different sources, ask: what actually guarantees the alignment? If the answer is "they're both sorted the same way and I filtered them identically," you have a latent bug waiting for the day someone changes one of the filters.
zip() silently truncates and never verifies alignment — when pairing data from independent sources, join on a key or pass strict=True, never trust positional correspondence.
