Every time you write for product in cart:, Python is doing two distinct jobs behind the scenes. It's asking the cart "can I walk through your items?", and then it's walking through them one at a time. Python splits those two jobs onto two different kinds of object: an iterable (the thing that can produce a walker) and an iterator (the walker itself). This lesson untangles those two roles, explains why Python keeps them separate, and shows the iter() and next() built-ins that let you control the walk by hand.
An iterable is anything you can ask for an iterator. A list is iterable. A string is iterable. A dict is iterable. So is a tuple, a set, a range, and an open file.
An iterator is a stateful cursor. It remembers where you are in the walk and produces the next item when you ask. Calling next(iterator) returns the next value; when there are no more values, it raises StopIteration.
The relationship is one-to-many. A single list can hand out many fresh iterators, each starting from the beginning. The list doesn't move when you walk it; the iterator does.
iter(cart) asked the list for a walker. Each next(walker) advanced that walker by one item. The list cart itself never changed. We didn't pop anything off it; we just stepped through it from the outside.
The for loop works on "anything iterable." This lesson is the answer to "what does iterable actually mean?" For now, treat the two roles as a fact about Python and learn to spot them.
iter() and next(): The Two Built-ins You NeedPython exposes the iterator machinery through two built-in functions.
iter(obj) takes an iterable and returns a fresh iterator over it. If obj isn't iterable, you get TypeError.
next(it) takes an iterator and returns its next value. When the iterator is exhausted, it raises StopIteration.
That's the whole core API. Every iteration construct in Python, the for loop, comprehensions, the in operator on sequences, the * unpacking operator, ultimately runs iter() once and then calls next() in a loop until StopIteration shows up.
Two things to notice. First, the iterator's type is list_iterator, not list. The list and the iterator are different objects with different jobs. Second, after three next() calls we've consumed every value. One more call and Python raises StopIteration:
StopIteration is the official "I'm done" signal. It isn't a bug; it's how iterators tell Python's loop machinery to stop. When you write for price in prices:, Python catches that exception for you and exits the loop cleanly, which is why you never see StopIteration in everyday code.
You can also supply a default to next() so the exception is swallowed:
With a second argument, next() returns it whenever the iterator is exhausted instead of raising. This is handy when you want to peek at the next value without crashing if the cart is empty.
Splitting iterable and iterator into two separate objects feels like extra machinery at first. A list could in principle "know where it is" all on its own. Most languages with for-each loops do exactly that. Python's split has three concrete benefits.
Because the cursor lives on the iterator, not on the list, you can ask the list for a fresh iterator any time. Each one starts from the beginning.
Each for loop calls iter(cart) once, gets a brand-new iterator, and walks it to exhaustion. The list itself never "moves." If lists tracked their own position, the second loop would print nothing.
Because iterators are separate objects, you can have two of them open on the same iterable and advance them independently.
cursor_a has advanced two steps. cursor_b was created from scratch and has advanced one step. They don't interfere because they each carry their own position. If position lived on the list, this pattern would be impossible without copying the list.
An iterator only has to produce the next value when you ask. It doesn't have to know what's coming after that, and the values don't have to exist in memory before you reach them. This is the whole reason generators work: they're iterators that compute each value on demand.
The lazy nature also matters for things you can only walk once, like data coming from a network socket or a file. Those producers can't restart; the only sensible model is "give me the next value if you have one." That's exactly what an iterator is.
The takeaway is that splitting iterable from iterator makes lazy iteration possible.
The core Python types all support iter(). Each one returns its own kind of iterator object, but they all behave the same way from your end: call next() to step, catch StopIteration when done.
Strings iterate one character at a time. A customer's name is a sequence of characters, and iter() treats it that way.
A range object is iterable but isn't a list. It stores the start, stop, and step, and produces numbers on demand when you walk it.
Notice that range(1001, 1004) itself isn't an iterator. It's an iterable that hands out an iterator when asked. You can prove it by walking it twice in two separate loops, just like a list.
A set is iterable, but the order in which it produces elements is an implementation detail. Don't rely on it.
A dict is iterable too, and by default iter(some_dict) walks its keys.
The default cursor is over keys. To walk values or key-value pairs, use the dict view methods.
stock.values() and stock.items() return view objects, which are themselves iterable. You can call iter() on either of them and step manually if you need to.
Cost: Dict views (keys(), values(), items()) don't copy anything. They're lightweight wrappers around the underlying dict, so iterating them is the same cost as iterating the dict directly. Calling list(stock.values()) would copy.
An open file object is iterable, and walking it yields one line at a time. This is the line-by-line streaming pattern used for file handling.
The file is iterable. iter(f) returns an iterator that produces each line in turn. When the file is exhausted, the next next() call raises StopIteration. That's exactly the contract for line in f: relies on under the hood.
for Uses iter() and next() Under the HoodWhen you write a for loop, Python does roughly this:
That's the entire mechanism. The for loop is sugar around iter(), next(), and a try/except for StopIteration.
The same engine drives comprehensions, generator expressions, * unpacking, the in operator on sequences, and built-ins like sum, max, min, any, all, and sorted. All of them accept anything iterable, and all of them use the same protocol.
sum(prices) runs iter(prices) once and calls next in a loop, accumulating the values. The generator expression p > 20 for p in prices is itself an iterator, and any() walks it the same way. Once you see iteration as "iter then next," every loop-like construct in Python starts to feel like one mechanism instead of many.
Here's the part that trips people up the first time they see it. Iterators are also iterables. Calling iter() on an iterator returns the iterator itself, not a fresh one. This is so a for loop over an iterator "just works" without an extra unwrap step.
The good news is that you can pass an iterator anywhere an iterable is expected, like sum, max, list, or another for loop. The trap is that once an iterator is exhausted, it stays exhausted. There's no "rewind."
The first list(cursor) walked the iterator to the end, draining it. The second call asked for a fresh walk, but the cursor was already at end-of-stream, so it returned nothing. To restart, you need a brand-new iterator from the original iterable.
Each call to iter(cart) produces a fresh cursor at position 0, and each list() then drains it independently.
This is the exact reason a for loop over a list works correctly twice in a row but a for loop over a generator doesn't. The list is the iterable, and each for builds its own iterator. The generator is the iterator, and once you've walked it, it's done.
The first loop drained the cursor. The second loop got nothing because the cursor was already at end-of-stream. Both loops were syntactically identical, but the second one silently produced zero iterations. That's a real bug magnet when you don't see the distinction. Re-running with a list directly works because for calls iter(list) afresh each time:
Same loops, different result, because the underlying object is iterable rather than an already-spent iterator. The rule of thumb: lists, tuples, dicts, sets, strings, ranges, and files are iterables you can iterate many times; the cursor objects returned by `iter()` are one-shot.
Cost: Calling iter() on a list is O(1); it just allocates a small cursor object. Walking the cursor is O(n) because it touches every element. Drains are cheap; restarts mean a fresh iter() call.
The split between iterable and iterator is easier to keep straight with a diagram. The iterable is the source. The iterator is the cursor. Walking the cursor produces values; the cursor stops by raising StopIteration.
The arrow labeled iter cart runs once. It produces the cursor. The arrows labeled next run repeatedly, each one advancing the cursor by one slot. When the cursor walks off the end, the next call raises StopIteration instead of returning a value. The for loop is exactly this picture, with the StopIteration arrow translated into "exit the loop."
There's one more useful detail to put on the picture: the iterator carries its own state. If you ask the iterable for a second cursor, you get a fresh one at position 0, and the two cursors don't interact.
Two cursors, two positions, one shared source. That's the model.
A more realistic example pulls these pieces together. Imagine a small order represented as a dict, and we want to walk through its fields manually instead of letting a for loop do it.
We pulled two items off the cursor by hand with next(), then let a for loop drain the rest. Mixing manual next() calls with a for loop on the same cursor is fine because both speak the same protocol. The for picks up where next() left off because the position lives on the cursor.
This pattern, peek the first few items by hand, then loop the rest, is exactly how you'd skip a header row in a CSV or grab a "next page" cursor from a stream. The shape of the code is the same regardless of where the data comes from.
| Type | Iterable? | Iterator type returned by iter() | Walks over |
|---|---|---|---|
list | yes | list_iterator | elements in order |
tuple | yes | tuple_iterator | elements in order |
str | yes | str_iterator | characters |
dict | yes | dict_keyiterator | keys |
dict.keys() | yes | dict_keyiterator | keys |
dict.values() | yes | dict_valueiterator | values |
dict.items() | yes | dict_itemiterator | (key, value) tuples |
set / frozenset | yes | set_iterator | elements, order undefined |
range | yes | range_iterator | numbers in the range |
| open file | yes | the file object itself | lines |
bytes / bytearray | yes | bytes_iterator / bytearray_iterator | integer byte values |
int, float, bool, None | no | n/a (raises TypeError) | n/a |
This isn't exhaustive, but it covers everything you'll meet in the first half of the course. The pattern carries: most "container-ish" types in Python are iterable, and a few non-container ones (open files) are too. The non-iterable types are the scalars: a single number or a None doesn't have a sequence of values to walk.
Once you can hold an iterator in a variable, you can build small utilities on top of it. Here's a function that returns the first n items of an iterable, stopping early if the iterable runs out:
The function works on any iterable, not just lists, because all it needs is iter() and next(). Pass it a dict and you get the first n keys. Pass it a file and you get the first n lines. Pass it a string and you get the first n characters.
That's the payoff of writing to the iterator protocol instead of a specific type. The function doesn't care where the values come from, as long as the source can hand out a cursor.
The standard library's itertools.islice does this same job more efficiently. The point of writing first_n by hand here is to show that the protocol is small enough to use directly.
A few patterns catch beginners often enough to be worth naming.
An iterator doesn't have a length, doesn't support indexing, and can only be walked once. Calling len(cursor) or cursor[0] raises TypeError.
If you need a length or indexing, materialize the iterator into a list first: items = list(cursor). Just remember that doing so consumes the iterator.
Once an iterator is exhausted, it's done. A second loop or a second list() call gets nothing. This is the most common surprise:
To walk twice, get two iterators from the iterable, or hold onto the iterable and re-iterate it directly.
for item in stock: walks the dict's keys, not its values. Mixing this up leads to confusing bugs when you expect prices and get product names instead.
Use stock.values() for values, stock.items() for both, or just remember that the bare dict gives keys.
range Is a ListA range looks list-like but isn't a list. It's an iterable that produces numbers on demand. You can iterate it many times, but you can't append to it, and iter(range(...)) returns a range_iterator, not a list.
Notice that range is iterable and re-iterable. list(ids) works twice. The thing you can't do is treat range as a mutable list.
StopIteration Where You Don't Need ToStopIteration only matters when you're calling next() by hand. Inside a for loop or a comprehension, Python handles the exception for you, and writing your own try/except StopIteration around a for loop is dead code.
What's wrong with this code?
The try/except StopIteration does nothing useful. The body of the for loop is reached only when an item has already been produced; StopIteration is raised by the iterator's next(), which Python already catches before handing the value to the body. The exception will never appear inside the try.
Fix:
The unnecessary try/except just clutters the code. Reach for explicit StopIteration handling only when you're calling next() directly and want to react to exhaustion yourself.
10 quizzes