A for loop is how you tell Python "do this work once for every item in a collection". Iterating over a list of products, walking through the characters in a customer's email, generating a sequence of numbers, processing every order in a batch: all of it goes through the same simple syntax. This lesson covers what for actually does, the helpers Python gives you to make iteration cleaner (range, enumerate, zip), and the few sharp edges that catch beginners.
A for loop has three parts: the keyword for, a variable that will hold each item in turn, and an iterable to pull items from.
Python takes the cart, hands you one element at a time, binds it to the name item, runs the indented block, then moves on. When the cart runs out, the loop stops.
The variable name is yours to choose. item, product, name, x, all of them work. Pick a name that says what each value means, the same way you'd name any other variable.
total starts at zero, and each pass through the loop adds one price. After the loop ends, total holds the sum.
Two details about that price variable that surprise people. First, it survives after the loop: once the loop finishes, price still holds the last value it was bound to (9.99 here). Python doesn't scope loop variables to the loop body the way some other languages do.
Second, if the iterable is empty, the loop body never runs and the loop variable never gets bound at all. Trying to use it after an empty loop raises NameError:
Nothing was printed inside the loop. Python checked the cart, found it empty, and skipped the body entirely.
An iterable is any object Python knows how to walk through one item at a time. Lists are the most common, but plenty of other types qualify: tuples, strings, dictionaries, sets, files, ranges, and others. For now, the intuition is enough: if you can ask "what's the next thing", it's iterable.
A for loop works the same way for every iterable type. The syntax doesn't change. Here's a string:
Each character of the string comes out in order. A string is iterable, so for works on it.
A tuple iterates the same way. Think of a tuple as a fixed-size list you can't change:
The same for x in y shape handles all of these. Once you learn one form, you've learned them all.
The diagram shows what every for loop is doing under the hood: ask the iterable for the next value, bind it to the loop variable, run the body, repeat. When the iterable says "no more", the loop ends.
Sometimes you don't have a list to iterate over; you just want to do something a specific number of times, or step through a range of numbers. That's what range() is for.
range() produces a sequence of integers on demand. The simplest form takes a single argument: a stop value. The sequence starts at 0 and goes up to (but not including) the stop value.
Five values: 0 through 4. The stop value is exclusive, which is the convention you'll see throughout Python (slicing follows the same rule). It feels off at first if you're used to inclusive ranges, but it makes the math cleaner: range(5) produces exactly 5 values, and range(len(items)) walks every valid index of items.
A two-argument call lets you specify both the start and the stop:
range(1, 6) starts at 1, stops before 6. Useful when you want to count from one instead of zero (for displaying order numbers to a customer, for example).
A three-argument call adds a step size:
range(0, 20, 5) starts at 0, stops before 20, and jumps by 5 each time. The step can be negative, which lets you count down:
range(5, 0, -1) starts at 5, stops before 0, and decrements by 1. With a negative step, the start should be larger than the stop, otherwise the range is empty.
A small reference for the three forms:
| Call | Produces |
|---|---|
range(stop) | 0, 1, 2, ..., stop - 1 |
range(start, stop) | start, start + 1, ..., stop - 1 |
range(start, stop, step) | start, start + step, start + 2 * step, ... (stops before passing stop) |
A common e-commerce use is generating a sequence of order numbers or processing a fixed batch of items:
Note the + 1 on the stop value. Because range() is exclusive on the stop, you write range(1, 4) to get 1, 2, 3. Forgetting that is one of the most common off-by-one bugs in Python.
Cost: range() doesn't build a list of numbers up front. It generates each value on demand. range(1_000_000_000) uses a tiny fixed amount of memory regardless of size, which is why it's safe to write for i in range(huge_number).
You will sometimes see code that loops over indices using range(len(some_list)):
This works, but it's not the Pythonic way. When you want both the index and the value, the right tool is enumerate(), which we'll meet next. Save range(len(...)) for the rare cases where you genuinely only need the index, like when you want to mutate the list at specific positions.
When you need both the position of an item and the item itself, enumerate() is the clean way to get both at once. It wraps any iterable and yields (index, value) pairs.
The for index, product in ... part unpacks each (index, value) pair into two names in one step. You can call them whatever you want; i, item and index, product and position, name all work.
By default enumerate() starts counting at 0, which lines up with list indices. If you want the count to start somewhere else, pass a start argument:
start=1 is the obvious choice when you're showing positions to a human (line numbers in a receipt, ranking in a search result, step numbers in a process). The underlying list still has the same indices; you're only changing what the counter prints.
Compare this to the range(len(...)) version from the previous section:
The output is identical, but the enumerate version reads better. There's no len() call, no [i] indexing, and the intent ("I want index and value") is right in the syntax. If you find yourself writing range(len(...)) and then indexing back into the list, replace it with enumerate().
Cost: enumerate() is lazy, like range(). It doesn't build a list of pairs up front; it produces each pair only when the loop asks for one. So enumerate(huge_list) is free until you start iterating.
A small e-commerce example: numbering items in a printed receipt.
Two layers of unpacking happen on the for line. enumerate() gives back (line_number, item) pairs, and the item itself is a (name, price) tuple from the cart. Wrapping the inner pair in parentheses tells Python to unpack it too. The result is three named values you can use directly in the format string.
When you have two collections that line up by position, zip() walks them together. It pairs the first element of each, then the second, then the third, and so on.
zip(products, prices) produces ("Wireless Mouse", 29.99), ("USB Cable", 4.99), ("HDMI Cable", 14.99). The for name, price in ... unpacks each pair. No index math, no [i] lookups, just the two values you actually want.
zip() accepts more than two iterables. Pass three and you get triples:
The catch with zip() is what happens when the iterables have different lengths. By default, zip() stops at the shortest one and silently drops the extras:
HDMI Cable is missing from the output because there's no third price. Whether that's a bug or a feature depends on the situation. If your data should always line up, you probably want to be told when it doesn't. Python 3.10 added zip(..., strict=True), which raises ValueError if the iterables have different lengths.
For data that's supposed to be aligned (one price per product, one stock count per product), use strict=True. It turns silent data loss into a loud error.
This lesson covers zip() at the level you need to use it in for loops.
Dictionaries are iterable too. The trick is knowing what you get when you iterate one. By default, for x in some_dict walks the keys, not the values, not the pairs.
If you want the values, the keys plus values, or just the values, use the matching dict method:
stock.items() yields (key, value) pairs that you unpack into two names. This is the most common pattern for "do something with both the product name and its count".
For values only, use .values():
A small thing that matters: writing for product in stock.keys(): is the same as for product in stock:. Both walk the keys. Calling .keys() is unnecessary in the loop header, and the shorter form is the one Python developers write. Save .keys() for when you need the keys view as a separate object (for set operations, for example).
Treat what you've seen here as the basics that get you through everyday loops.
A trap that catches every Python beginner: changing a list or dictionary while you're iterating over it. The rule is simple: don't.
You probably expected [25, 50, 30]: every price under 20 removed. Instead 14 survived. The reason is that the loop walks the list by index internally. After it processes index 0 (10), it removes that element, which shifts every other element down by one. The next iteration looks at index 1, but index 1 is now 8 (which used to be at index 2). The original 8 gets processed, but the loop has now skipped over 25. Removing items shifts the goalposts under the loop.
This bug is even worse for dictionaries: changing the size of a dict during iteration raises RuntimeError outright in modern Python.
The fix is to iterate over a copy, or build a new collection from scratch. Both approaches work; pick the one that fits.
The copy approach uses a slice (prices[:]) or list(...) to make a snapshot the loop reads from, leaving the original safe to modify:
Now the loop walks a separate snapshot, so removing items from prices doesn't change what the loop sees next.
The build-a-new-list approach skips the mutation entirely:
Same result, no in-place removal, no surprises. This pattern (filter into a new list) is so common that Python has a more concise form for it called a list comprehension.
For the dictionary case, the same copy-or-rebuild trick works:
list(stock) builds a snapshot of the keys before the loop starts. The loop walks the snapshot, and del stock[product] modifies the original dict without breaking the iteration.
Cost: Copying with prices[:] allocates a new list of references. For small to medium lists this is invisible. For huge lists where you only need to remove a few items, building a new list with the kept items is usually cheaper than copying everything and then mutating in place.
A for loop's body can contain another for loop. The inner loop runs from start to finish for every single pass of the outer loop. This is how you walk pairs, grids, or any combination of two collections.
A simple e-commerce example: an order has multiple items, and you need to iterate every item in every order.
The outer loop walks the list of orders. For each order, the inner loop walks the items in that order. Indentation is what tells Python where each loop body starts and ends, so be careful with it.
Another common pattern: building a price grid, where every row in one collection pairs with every column in another. Suppose you want to compute the total cost for every quantity of every product.
Three products and three quantities give nine combinations. The empty print() after the inner loop adds a blank line between groups, which is purely cosmetic.
Nested loops are powerful, but they multiply work. Two nested loops over collections of size N do N x N iterations. Three nested loops do N x N x N. For a small product catalog, this is fine. For ten thousand products and ten thousand quantities, you're looking at a hundred million iterations, which gets slow fast.
Cost: A nested loop over two collections of size N runs N x N times. If both are 1,000 items, that's a million iterations. Watch the sizes when you nest. There's almost always a better data structure (a set for membership, a dict for lookup) that turns one of the loops into an O(1) check.
A real e-commerce case: checking which wishlist items are also in stock. The naive nested-loop version:
This works, but it's O(wishlist x in_stock). Converting in_stock to a set makes the inner check O(1):
Same answer, but the inner loop is gone. The trade-off is the cost of building the set up front, which is a one-time O(n). For repeated lookups, that cost pays for itself many times over.
The lesson isn't "never nest loops". It's "when you find yourself nesting, ask whether one of the loops is really doing a lookup that a set or dict could handle in one step".
Sometimes you want to repeat work a fixed number of times and don't actually care about the loop counter. The Python convention is to use _ (underscore) as the variable name to signal "I don't care about this value":
_ is a regular variable in Python; it's just convention, not syntax. Using it tells the reader "this name is intentionally unused". Linters and other tools also recognize the convention and won't warn about an unused variable.
The same convention applies inside unpacking. If you want only the value from enumerate() and not the index (which is rare, since at that point you'd just iterate the iterable directly), you can write:
That's a contrived example because you could just loop over products directly. The convention is more useful with zip() or with tuples that have data you don't need.
10 quizzes