← All tutorials
PythonData Analysis

Advanced Python For Loops: enumerate, zip, and Fast Pandas Iteration

Basic for loops are straightforward, but Python gives you tools like enumerate, zip, the else clause, and comprehensions that make iteration cleaner. Once your data lives in pandas, this guide also measures why looping row by row is usually the wrong move.

You learned for item in items: early on, and it works. But Python’s for loop has a deeper toolkit: enumerate() hands you the index without a manual counter, zip() lets you walk two sequences side by side, the else clause cleanly handles “nothing matched,” and comprehensions replace an entire loop with one expression. The same idea — describe the result you want instead of the steps to get there — is also why efficient pandas code almost never loops over rows by hand.

Here’s where it gets confusing: these all look like syntax sugar, so it’s easy to reach for the wrong one, or to not notice .iterrows() is catastrophically slow until a DataFrame gets big enough to hurt. This guide covers the language-level patterns first, then finishes with the question every pandas user eventually asks: what do you use instead of looping over rows one at a time? If you’re still getting comfortable with basic loops, our Python Basics course covers the fundamentals first; and if you’re already doing per-group math in pandas, grouping data with groupby often answers the question without looping at all.

The Mental Model: Enrich, Control, or Replace

Every “advanced” for-loop technique does one of three things to the basic for item in items: pattern:

  1. Enrich what each pass hands you — enumerate() adds the index, zip() adds a second (or third) sequence, so you get more information per iteration without extra bookkeeping.
  2. Control how the loop decides to stop — break, continue, and the loop’s else clause change what happens at the edges, not what happens in the middle.
  3. Replace the loop entirely — a comprehension states the result you want ([expression for item in iterable]) and lets Python handle the iteration. Inside pandas, a vectorized operation takes this one step further: it states the result for every row at once, computed in C, with no per-row Python bytecode at all.
Log-scale bar chart showing rows processed per second for four ways to loop over a 20,000-row pandas DataFrame: .iterrows() reached 58,817 rows per second, .apply(axis=1) reached 198,576 rows per second, .itertuples() reached 1,708,994 rows per second, and a fully vectorized calculation reached 33,999,150 rows per second.

The rest of this guide walks through each category in order, then spends real time on “replace” inside pandas, where the performance gap is largest: looping over a DataFrame with .iterrows() runs roughly 580 times slower than the same calculation as one vectorized expression, verified later in this post.

enumerate(): Stop Counting Manually

The manual way to track an index:

fruits = ["apple", "banana", "cherry", "date"]

index = 0
for fruit in fruits:
    print(f"{index}: {fruit}")
    index += 1
0: apple
1: banana
2: cherry
3: date

This works but it’s verbose, and you have to remember to increment index every iteration. enumerate() does it automatically:

for index, fruit in enumerate(fruits):
    print(f"{index}: {fruit}")
0: apple
1: banana
2: cherry
3: date

Same output, cleaner code. enumerate() returns pairs of (index, item), so you unpack them directly in the loop header — the “enrich” category from the mental model above, at its simplest.

Starting from a Different Number

Need indices that start at 1 instead of 0? Pass a start argument:

for rank, fruit in enumerate(fruits, start=1):
    print(f"#{rank}: {fruit}")
#1: apple
#2: banana
#3: cherry
#4: date

This is cleaner than index + 1 everywhere. Reach for enumerate() only when you use the index for something — updating a list in place, comparing neighbors, formatting output. Otherwise, plain for fruit in fruits: says what you mean more clearly.

zip(): Iterate Over Multiple Sequences in Parallel

When you have two lists and need corresponding pairs, zip() is your tool:

names = ["Alice", "Bob", "Charlie"]
scores = [85, 92, 78]

for name, score in zip(names, scores):
    print(f"{name}: {score}")
Alice: 85
Bob: 92
Charlie: 78

zip() pairs up elements from both lists, stopping when the shorter one runs out. You can zip more than two sequences:

cities = ["New York", "London", "Tokyo"]
temps = [22, 15, 28]
units = ["C", "C", "C"]

for city, temp, unit in zip(cities, temps, units):
    print(f"{city}: {temp}°{unit}")
New York: 22°C
London: 15°C
Tokyo: 28°C

Building Dictionaries with zip()

Pair up keys and values to create a dictionary in one line:

keys = ["name", "age", "city"]
values = ["Alice", 30, "Seattle"]

person = dict(zip(keys, values))
print(person)
{'name': 'Alice', 'age': 30, 'city': 'Seattle'}

This pattern comes up when parsing data from APIs or CSV rows.

Handling Mismatched Lengths

By default, zip() stops at the shortest sequence:

a = [1, 2, 3]
b = ["x", "y"]

print(list(zip(a, b)))
[(1, 'x'), (2, 'y')]

The 3 is silently dropped. If you want every element and need to fill in missing values, use itertools.zip_longest():

from itertools import zip_longest

print(list(zip_longest(a, b, fillvalue="?")))
[(1, 'x'), (2, 'y'), (3, '?')]

The else Clause: Run Code Only If the Loop Never Breaks

Python’s for loop supports an else block — part of the “control” category. It runs whenever the loop finishes without hitting break, which includes finishing normally and not running at all:

numbers = [1, 3, 5, 7, 9]

for n in numbers:
    if n % 2 == 0:
        print(f"Found an even number: {n}")
        break
else:
    print("No even numbers found")
No even numbers found

The else block ran because the loop never hit break. Change the list to include an even number:

numbers = [1, 3, 5, 8, 9]

for n in numbers:
    if n % 2 == 0:
        print(f"Found an even number: {n}")
        break
else:
    print("No even numbers found")
Found an even number: 8

Now else doesn’t run — the break skipped it.

Practical Use Case: Search Loops

The else clause shines when searching for something:

def find_user(users, target_id):
    for user in users:
        if user["id"] == target_id:
            print(f"Found: {user['name']}")
            break
    else:
        print("User not found")

The else makes the “not found” case explicit and avoids a separate flag variable to track whether the loop matched anything.

Comprehensions: Replace the Loop Entirely

A list comprehension builds a list in one expression — the “replace” category:

numbers = [1, 2, 3, 4, 5]

# Loop version
squares = []
for n in numbers:
    squares.append(n ** 2)

print(squares)
[1, 4, 9, 16, 25]
# Comprehension version
squares = [n ** 2 for n in numbers]
print(squares)
[1, 4, 9, 16, 25]

Same result, one line. The pattern is [expression for item in iterable].

Filtering and Transforming with Conditions

Add a condition to filter elements:

evens = [n for n in numbers if n % 2 == 0]
print(evens)
[2, 4]

Only elements that pass the if condition make it into the list. A ternary expression transforms differently based on a condition instead:

labels = ["even" if n % 2 == 0 else "odd" for n in numbers]
print(labels)
['odd', 'even', 'odd', 'even', 'odd']

The ternary (value_if_true if condition else value_if_false) goes before the for.

Flattening Nested Data

Comprehensions can replace nested loops too. The loop version of flattening a grid:

grid = [[1, 2, 3], [4, 5, 6], [7, 8, 9]]

flat = []
for row in grid:
    for cell in row:
        flat.append(cell)

print(flat)
[1, 2, 3, 4, 5, 6, 7, 8, 9]

And the comprehension version, which mirrors the nested loop’s order — outer loop first, inner loop second:

flat = [cell for row in grid for cell in row]
print(flat)
[1, 2, 3, 4, 5, 6, 7, 8, 9]

When to Use a Comprehension

Use a comprehension when:

  • The loop builds a list by transforming or filtering elements
  • The logic fits on one readable line
  • You’d otherwise initialize an empty list and append in every iteration

Use a regular loop when:

  • The logic is complex (multiple conditions, nested ifs, function calls)
  • You’re doing something other than building a list (side effects, printing, updating a database)
  • The comprehension would exceed about 80 characters

Comprehensions are for clarity, not cleverness. If it’s hard to read, it’s too complex. The Python documentation on list comprehensions has more examples if you want to go further.

Dictionary and Set Comprehensions

The same syntax works for dictionaries and sets:

# Dictionary comprehension
squares_dict = {n: n ** 2 for n in range(1, 6)}
print(squares_dict)
{1: 1, 2: 4, 3: 9, 4: 16, 5: 25}
# Set comprehension
unique_lengths = {len(word) for word in ["apple", "banana", "pear", "kiwi"]}
print(unique_lengths)
{4, 5, 6}

Same filtering and transformation rules apply.

Looping Over Dictionaries

By default, looping over a dictionary gives you its keys:

scores = {"Alice": 85, "Bob": 92, "Charlie": 78}

for name in scores:
    print(name)
Alice
Bob
Charlie

For key-value pairs, use .items(); for just the values, .values():

for name, score in scores.items():
    print(f"{name}: {score}")
Alice: 85
Bob: 92
Charlie: 78

range(), break, and continue

range(stop) is common, but range(start, stop, step) gives you more control — including counting backwards:

for i in range(10, 0, -1):
    print(i, end=" ")
10 9 8 7 6 5 4 3 2 1

break exits the loop immediately; continue skips the rest of the current iteration:

for i in range(10):
    if i % 2 == 0:
        continue
    if i == 7:
        break
    print(i, end=" ")
1 3 5

Even numbers are skipped by continue, then the loop stops entirely once it reaches 7. Both keywords only affect the innermost loop they’re in.

Looping Over pandas DataFrames: When to Skip the Loop

Everything above applies to plain Python lists and dicts. Once your data lives in a pandas DataFrame, the calculus changes: pandas stores each column as a contiguous block of memory, and it can compute an operation across an entire column in one C-level pass — a manual Python for loop can’t touch that, no matter how cleanly it’s written.

A Dataset You Can Reproduce

Imagine a small courier company that charges a delivery fee based on distance, package weight, and whether the customer paid for priority handling. Generate 20,000 synthetic deliveries with a fixed seed so your numbers match mine exactly:

import numpy as np
import pandas as pd

rng = np.random.default_rng(42)
n = 20_000

deliveries = pd.DataFrame({
    "distance_km": rng.uniform(0.5, 18, size=n).round(2),
    "weight_kg": rng.uniform(0.2, 15, size=n).round(2),
    "priority": rng.choice([True, False], size=n, p=[0.15, 0.85]),
})

deliveries.head()
   distance_km  weight_kg  priority
0        14.04      11.16     False
1         8.18      14.94      True
2        15.53       0.81      True
3        12.70       3.82      True
4         2.15       9.05     False

This is a seeded synthetic dataset — there’s no real courier behind it, and there doesn’t need to be one, since the point here is measuring loop performance, not analyzing real delivery patterns. (The numbers in this post come from pandas 3.0.3 and numpy 2.4.6; exact seconds will differ on your machine, but the relative order — vectorized fastest, then .itertuples(), then .apply(), then .iterrows() last — holds across machines and pandas versions.)

The fee rule: a €2.50 base plus €0.60 per kilometer and €0.15 per kilogram, a 20% surcharge if the package is over 8kg, and a flat €1.50 add-on for priority handling. time_it() runs each method 3 times and keeps the fastest, which is the standard way to cancel out one-off system noise:

import time

BASE_FEE = 2.50
RATE_PER_KM = 0.60
RATE_PER_KG = 0.15
HEAVY_SURCHARGE = 1.20
HEAVY_THRESHOLD = 8.0
PRIORITY_SURCHARGE = 1.50

def fee_for_row(distance_km, weight_kg, priority):
    fee = BASE_FEE + distance_km * RATE_PER_KM + weight_kg * RATE_PER_KG
    if weight_kg > HEAVY_THRESHOLD:
        fee *= HEAVY_SURCHARGE
    if priority:
        fee += PRIORITY_SURCHARGE
    return fee

def time_it(fn, repeats=3):
    best = None
    for _ in range(repeats):
        start = time.perf_counter()
        result = fn()
        elapsed = time.perf_counter() - start
        best = elapsed if best is None else min(best, elapsed)
    return best, result

.iterrows(): The Slow, Obvious Way

.iterrows() looks like the natural translation of a regular Python loop — for each row, do something:

def run_iterrows():
    return [fee_for_row(row["distance_km"], row["weight_kg"], row["priority"])
            for _, row in deliveries.iterrows()]

iterrows_elapsed, fees_iterrows = time_it(run_iterrows)
print(f"iterrows: {len(deliveries)} rows in {iterrows_elapsed:.3f}s "
      f"({len(deliveries) / iterrows_elapsed:,.0f} rows/sec)")
iterrows: 20000 rows in 0.340s (58,817 rows/sec)

That’s the slowest option of the four in this post, by a wide margin. .iterrows() has to rebuild a pandas.Series object for every single row, which carries real overhead multiplied by 20,000.

.itertuples(): Same Idea, Much Faster

.itertuples() returns lightweight namedtuples instead of Series, which is dramatically cheaper to construct:

def run_itertuples():
    return [fee_for_row(row.distance_km, row.weight_kg, row.priority)
            for row in deliveries.itertuples()]

itertuples_elapsed, fees_itertuples = time_it(run_itertuples)
print(f"itertuples: {len(deliveries)} rows in {itertuples_elapsed:.3f}s "
      f"({len(deliveries) / itertuples_elapsed:,.0f} rows/sec)")
itertuples: 20000 rows in 0.012s (1,708,994 rows/sec)

About 29 times faster than .iterrows() on the same 20,000 rows, for the same row-by-row logic — just by switching which iteration method you call. If you must loop row by row in pandas, this is the one to reach for, not .iterrows().

.apply(axis=1): A Middle Ground That Looks Faster Than It Is

.apply() with axis=1 calls your function once per row and lets pandas handle the bookkeeping:

def run_apply():
    return deliveries.apply(
        lambda row: fee_for_row(row["distance_km"], row["weight_kg"], row["priority"]), axis=1
    )

apply_elapsed, fees_apply = time_it(run_apply)
print(f"apply: {len(deliveries)} rows in {apply_elapsed:.3f}s "
      f"({len(deliveries) / apply_elapsed:,.0f} rows/sec)")
apply: 20000 rows in 0.101s (198,576 rows/sec)

Faster than .iterrows(), but slower than .itertuples() — and much slower than the vectorized version below. .apply(axis=1) still runs your Python function once per row; it’s just hidden inside pandas’ own loop instead of yours.

Vectorized: Describe the Result, Skip the Loop

The fastest option skips row-by-row logic entirely and operates on whole columns at once:

def run_vectorized():
    fee = BASE_FEE + deliveries["distance_km"] * RATE_PER_KM + deliveries["weight_kg"] * RATE_PER_KG
    fee = fee.where(deliveries["weight_kg"] <= HEAVY_THRESHOLD, fee * HEAVY_SURCHARGE)
    fee = fee.where(~deliveries["priority"], fee + PRIORITY_SURCHARGE)
    return fee

vectorized_elapsed, fee = time_it(run_vectorized)
print(f"vectorized: {len(deliveries)} rows in {vectorized_elapsed:.4f}s "
      f"({len(deliveries) / vectorized_elapsed:,.0f} rows/sec)")
print(f"speedup vs iterrows: {iterrows_elapsed / vectorized_elapsed:.1f}x")
vectorized: 20000 rows in 0.0006s (33,999,150 rows/sec)
speedup vs iterrows: 578.0x

Series.where(condition, other) keeps the original value where condition is True and swaps in other where it’s False — read it as “keep this, unless.” All four methods here produce identical fees for every row — 578.0x faster than .iterrows(), 19.9x faster than .itertuples(), 171.2x faster than .apply(); see the pandas guide to iteration for the library’s own take on when (rarely) a loop is still the right call.

Three Gotchas Worth Knowing

The else clause runs even if the loop body never executes at all — it’s not only about break. An empty iterable never hits break, so else still fires:

empty = []
for item in empty:
    print("never runs")
else:
    print("else still runs on an empty iterable")
else still runs on an empty iterable

If your else block assumes the loop processed at least one item, check for that separately.

.iterrows() quietly changes your dtypes. Because each row becomes a single pandas.Series, and a Series can only have one dtype, mixed-type rows get upcast — usually to float64:

small = pd.DataFrame({"package_count": [3, 1, 5], "distance_km": [4.5, 12.0, 2.25]})
print(small.dtypes)

for _, row in small.iterrows():
    print(row["package_count"], type(row["package_count"]).__name__)
package_count      int64
distance_km      float64
dtype: object
3.0 float64
1.0 float64
5.0 float64

package_count is an integer column, but every value comes back as a float64 inside the loop. .itertuples() doesn’t have this problem, since it hands back a namedtuple that preserves each column’s own type.

.apply(axis=1) is not vectorized, no matter how it looks. It reads like a one-liner and lives on the same DataFrame object as genuinely vectorized methods, but the timing above shows it’s 171 times slower than the vectorized version on this dataset — because it’s still a Python function call per row underneath.

Wrapping Up

Python’s for loop has three moves beyond the basics: enrich what each pass gives you (enumerate, zip), control how it ends (break, continue, else), or replace it entirely by describing the result you want (comprehensions, and — inside pandas — vectorized operations). For DataFrames specifically:

  • .iterrows() → avoid it; it’s the slowest option here, by roughly 580 times
  • .itertuples() → the right choice when you genuinely need row-by-row logic
  • .apply(axis=1) → convenient, but still a per-row Python call — measure before trusting it
  • Vectorized operations → the default to reach for whenever the math can be expressed column-wise

If you want to go deeper on the pandas side — aggregating instead of looping, reshaping data, and cleaning it before either — the Apply, Map, and Transform lesson in our free Python for Data Analytics course picks up exactly where this post leaves off.

More tutorials