zip() in Python: loop over two lists at once
zip pairs items by position and stops at the shortest input, discarding the rest silently. strict=True is the fix.
zip() pairs up items from two or more sequences by position, so you can loop over them together:
names = ["Ada", "Grace"]
ages = [36, 45]
for name, age in zip(names, ages):
print(name, age)
Ada 36
Grace 45
Without it you would loop over indices and reach into both lists by hand. zip removes the index entirely, which removes the chance of getting it wrong.
Every output on this page came from Python 3.13 through Pyodide 0.28.3.
It stops at the shortest input
This is the behaviour that matters most, and it is silent:
names = ["Ada", "Grace", "Alan"]
ages = [36, 45]
list(zip(names, ages))
# [('Ada', 36), ('Grace', 45)]
Three names, two ages, two pairs. Alan is gone. No error, no warning, and the loop body simply never sees him.
That is fine when truncation is what you want. It is a data-loss bug when the lists were supposed to be the same length and one of them was short because something upstream went wrong.
Since Python 3.10 you can ask for the check:
list(zip(names, ages, strict=True))
# ValueError: zip() argument 2 is shorter than argument 1
The message names which argument was short. Use strict=True whenever the inputs are meant to match, which is most of the time. It costs nothing and converts a silent wrong answer into a stack trace.
It is lazy, and single-use
zip does not build a list. It returns a zip object that produces pairs on demand:
z = zip([1, 2], ["a", "b"])
type(z).__name__ # 'zip'
That makes it cheap on large inputs, and it has a consequence people hit exactly once:
z = zip([1, 2], ["a", "b"])
list(z) # [(1, 'a'), (2, 'b')]
list(z) # []
The second pass is empty. A zip is an iterator, and once consumed it is exhausted. If you need the pairs more than once, materialise them with list(zip(...)) and keep that.
This is the cause of the classic "my loop ran the first time and did nothing the second time" bug, and it applies to map, filter and generators for the same reason. The Python lambda post shows the same empty second pass with map.
Unzipping
The same function reverses the operation, using * to unpack the pairs as separate arguments:
pairs = [(1, "a"), (2, "b")]
nums, letters = zip(*pairs)
# nums is (1, 2), letters is ('a', 'b')
Note that you get tuples back, not lists. Wrap them in list() if you need to modify them.
zip(*rows) on a list of equal-length rows transposes it, turning rows into columns. It is a neat trick and worth a comment when you use it, because it is not obvious on first reading. The * is the same call-site unpacking covered in *args and **kwargs.
Building a dictionary from two lists
One of the most useful applications, and a one-liner:
dict(zip(["a", "b"], [1, 2]))
# {'a': 1, 'b': 2}
Keys from the first sequence, values from the second. This is the standard way to attach a header row to a data row, and it is why zip turns up constantly in CSV handling.
Bear in mind the truncation rule still applies. A header with more columns than the row produces a dictionary that is quietly missing keys, which is exactly the case for strict=True.
More than two sequences
zip takes any number:
for name, age, city in zip(names, ages, cities):
...
It still stops at the shortest, and strict=True still reports which argument was short.
Combining with enumerate
When you want the position as well as the paired items, nest the unpacking:
for i, (name, age) in enumerate(zip(names, ages)):
print(i, name, age)
0 Ada 36
1 Grace 45
The parentheses around (name, age) are required. enumerate yields a pair of (index, item), and here the item is itself a pair, so you unpack two levels at once. Leaving the parentheses out is a common error and produces an unpacking failure rather than a helpful message.
The patterns worth memorising
Four uses cover most of what zip is for in real code.
Attaching a header row to data rows, which is the CSV case:
header = ["name", "age"]
for row in rows:
record = dict(zip(header, row))
Each row becomes a dictionary keyed by column name, which is far easier to read than row[0] and row[1] scattered through the code.
Comparing two sequences position by position:
changes = [(a, b) for a, b in zip(before, after) if a != b]
Walking coordinates or paired values:
for x, y in zip(xs, ys):
plot(x, y)
Summing or combining in parallel:
totals = [a + b for a, b in zip(list1, list2)]
In each case the alternative is a range(len(...)) loop indexing into both, which is longer and can go out of bounds. zip cannot go out of bounds, because it stops when the first sequence does.
Pairing each item with the next one
A neat trick that comes up more than you would expect: zipping a sequence with itself, offset by one, gives you consecutive pairs.
values = [1, 4, 9, 16]
list(zip(values, values[1:]))
# [(1, 4), (4, 9), (9, 16)]
values[1:] is the same list starting one item later, so position 0 of one lines up with position 1 of the other. The truncation rule does the rest: the result is one shorter than the input, which is exactly the number of adjacent pairs.
That gives you differences between consecutive readings, gaps between timestamps, or a check that a list is sorted, all without index arithmetic:
deltas = [b - a for a, b in zip(values, values[1:])]
# [3, 5, 7]
is_sorted = all(a <= b for a, b in zip(values, values[1:]))
# True
When not to use it
When the data belongs together. Two parallel lists that must stay aligned are fragile: any operation on one that is not mirrored on the other corrupts the relationship silently. A list of tuples, or of small objects, keeps related values together and removes the class of bug entirely. zip is the right tool for combining lists you were given, not the right design for lists you are creating.
When you want every item from the longer one. itertools.zip_longest pads the short sequence instead of truncating:
from itertools import zip_longest
list(zip_longest(names, ages, fillvalue=None))
# [('Ada', 36), ('Grace', 45), ('Alan', None)]
That is the third option alongside truncating and raising, and choosing between the three deliberately is the whole point of knowing the default.
Working with dictionaries
zip and dictionaries combine in three ways that come up constantly.
Building one, covered above: dict(zip(keys, values)).
Taking one apart into parallel sequences:
keys, values = zip(*scores.items())
Looping two dictionaries together by their shared keys, which zip does not do directly, because zipping two dictionaries pairs their keys in insertion order rather than matching them:
for k in a.keys() & b.keys(): # set intersection of the keys
print(k, a[k], b[k])
That last one is worth knowing because zip(a, b) on two dictionaries runs without error and produces pairs of keys that have nothing to do with each other. It is a silent wrong answer of exactly the kind this page is about, and the fix is to match on keys rather than on position.
The general principle: zip pairs by position, always. Any time position is not the relationship you mean, zip is the wrong tool even when it runs.
Three common mistakes
Assuming it raises on mismatched lengths. It does not. It truncates silently, and the data you lose is at the end where you are least likely to look. strict=True is the fix and should be your default when the lengths are meant to match.
Reusing the zip object. The second pass gives nothing. If you need it twice, wrap it in list() once and keep that.
Forgetting the inner parentheses with enumerate:
for i, name, age in enumerate(zip(names, ages)): # fails
for i, (name, age) in enumerate(zip(names, ages)): # correct
enumerate yields exactly two things: the index, and the item. When the item is itself a pair, the parentheses tell Python to unpack that second thing into two names.
Checking lengths yourself
When you cannot use strict=True, because you are on an older Python or the truncation is conditional, assert it plainly:
if len(names) != len(ages):
raise ValueError(f"{len(names)} names but {len(ages)} ages")
Putting the counts in the message is what makes this worth writing. "Lengths differ" tells you what you already knew; "3 names but 2 ages" tells you which side is short and by how much, which is usually enough to identify the cause without opening a debugger.
This check belongs where the lists are created or received, not where they are zipped. By the time you are looping, the mistake happened somewhere else.
Quick reference
zip(a, b) # pairs, stops at the shortest
zip(a, b, strict=True) # raises if lengths differ (3.10+)
list(zip(a, b)) # materialise, so you can reuse it
dict(zip(keys, values)) # build a dictionary
nums, letters = zip(*pairs) # unzip into tuples
zip_longest(a, b, fillvalue=0) # pad instead of truncate
Ready to loop over real data? Start with the Python track, or read Python for loops for the construct zip feeds, and range explained for when you need positions instead.
More from the blog
What if __name__ == '__main__' actually does in Python
In Python, if __name__ == '__main__' runs code only when you run the file directly, not when it is imported. What __name__ holds and when you need it.
Read moreHow to reverse a list in Python (and a string)
To reverse a list in Python, use reverse() to change it in place, [::-1] for a reversed copy, or reversed() to loop backwards. Which to use and why.
Read moreReady to write some code?
Put this into practice - start your first free lesson. No setup, no credit card.