How to loop over a list in Python

How to loop over a list in Python

Let’s get one thing straight. When you need to go through the items in a list, one by one, your first, second, and third thought should be the humble for loop. This isn’t your grandfather’s C-style loop, a clumsy contraption of initializers, conditions, and incrementors just waiting to trip you up with an off-by-one error. No, the Python for loop is an elegant tool for a more civilized age.

It’s what we call a “for-each” loop. It does exactly what it says on the tin: for each item in the collection, it performs an action. Look how clean this is:

superheroes = ["Batman", "Wonder Woman", "Spider-Man", "Iron Man"]

for hero in superheroes:
    print(f"{hero} is ready for action!")

What’s happening here? Python handles the messy business of iteration for you. On the first pass, the variable hero is assigned the value "Batman". On the second pass, it’s "Wonder Woman", and so on, until the list is exhausted. You don’t have to declare hero beforehand. You don’t have to manually pull items out of the list using an index like superheroes[i]. The loop gives you the item itself, directly. This is a profound shift if you’re coming from other languages. It’s a shift from thinking about the mechanics of the loop to thinking about the data you’re working with.

This design philosophy isn’t just about saving a few keystrokes. It’s about writing code that is more readable and less prone to bugs. When you read for hero in superheroes:, the intent is crystal clear. There’s no mental gymnastics required to parse what for (int i = 0; i < superheroes.length; i++) is trying to accomplish. You are directly iterating over the elements. This is the Pythonic way, and for 90% of your list iteration needs, it is the only way you should consider. It's your workhorse. It's reliable, it's clean, and it keeps you from shooting yourself in the foot with manual index management. The beauty of this approach is its simplicity and directness; you're telling the computer what you want (each item), not how to get it (by incrementing an index from 0 to the length of the list). This declarative style frees you up to focus on the actual problem you're trying to solve inside the loop, rather than the boilerplate of just getting there. The cognitive overhead is dramatically lower, and in programming, reducing cognitive overhead is the name of the game. Every time you don't have to think about a potential off-by-one error is a small victory for sanity.

When you absolutely need the index

But I'll concede, there are times when the simple for hero in superheroes: loop isn't quite enough. What if you need to know the position of the item? What if you're trying to print a numbered list of your top ten favorite text editors, or you need to modify every other element in a list? This is where the temptation to revert to old, bad habits creeps in.

The programmer still thinking in C, Java, or PHP will immediately write something like this:

superheroes = ["Batman", "Wonder Woman", "Spider-Man", "Iron Man"]

# This is not the Python way. Avoid it.
for i in range(len(superheroes)):
    hero = superheroes[i]
    print(f"Hero #{i}: {hero}")

Stop right there. What is this abomination? We've abandoned the clean, declarative style of the for-each loop and regressed to manually managing an index. We're iterating over a sequence of numbers, not over the collection itself. We've reintroduced the clunky superheroes[i] syntax. This code is noisier, less direct, and fundamentally un-Pythonic. It forces the reader to mentally map the index i back to the item in the list, an unnecessary cognitive leap that the simple for-each loop so elegantly eliminates. Don't do this. There's a proper tool for this job.

When you absolutely, positively need the index along with the item, Python provides the built-in enumerate function. It's the correct, idiomatic solution to this problem, and it's beautiful.

enumerate takes your list and wraps it in an enumerate object. When you loop over this object, it yields a tuple containing the count (the index) and the value from the list on each iteration. It looks like this:

superheroes = ["Batman", "Wonder Woman", "Spider-Man", "Iron Man"]

# The correct, Pythonic way to get the index and item
for index, hero in enumerate(superheroes):
    print(f"Hero #{index}: {hero}")

Look how clean that is! The line for index, hero in enumerate(superheroes): reads like plain English. It clearly states its intent: "for each index and hero in the enumerated list of superheroes...". We get both the index and the item unpacked into their own variables on each pass. No more range(len()) nonsense. No more manual indexing. We're working directly with the data we need, in the most straightforward way possible.

This isn't just a matter of style. It's about writing code that is more robust and easier to reason about. If you see a loop using enumerate, you know instantly that the programmer needed both the position and the value. If you see range(len(some_list)), you should be suspicious. It's often a sign that the programmer either doesn't know about enumerate or is about to do something questionable, like modifying the list while iterating over it, which is a whole other can of worms. Better yet, if you need a numbered list that starts at 1 instead of 0, you don't even need to do the index + 1 math yourself. You can just tell enumerate where to begin:

for i, hero in enumerate(superheroes, start=1):
    print(f"Rank {i}: {hero}")

This is the kind of thoughtful design that makes Python a joy to use. It provides the right tool for the job, allowing you to express your intent clearly and concisely. Using enumerate when you need an index is a hallmark of a programmer who has moved beyond the basics and started to think in Python.

Embrace the list comprehension

So far, we've talked about iterating over a list to do something, like printing each item. But a far more common task is to create a new list based on the items in an existing list. You have a list of numbers, and you want a new list of those numbers, squared. You have a list of names, and you want a new list of those names, all in uppercase. The novice programmer, and even many seasoned developers stuck in their old ways, will reach for a pattern that looks like this:

words = ["the", "quick", "brown", "fox"]
uppercase_words = []
for word in words:
    uppercase_words.append(word.upper())

# uppercase_words is now ['THE', 'QUICK', 'BROWN', 'FOX']

There's nothing technically wrong with this code. It works. But it's verbose. It's boilerplate. You're creating an empty list, explicitly looping, performing an operation, and manually appending the result. It takes four lines to express a single, simple idea: "create a new list by uppercasing every word in the old list". This pattern is so common, so fundamental, that Python has a specialized, more powerful syntax for it. And if you're not using it, you are writing bad Python code. I'm sorry, but it's true.

This is the list comprehension. It's one of Python's most beloved and distinctive features. It's a way to build a list declaratively in a single, readable line. Let's rewrite the previous example:

words = ["the", "quick", "brown", "fox"]
uppercase_words = [word.upper() for word in words]

# And we get the exact same result.

Just look at that. One line. It's beautiful. It's concise. And once you get the hang of the syntax, it's arguably more readable than the four-line loop. The syntax follows the structure of the loop it replaces: [expression for item in list]. It reads like a sentence: "Give me the result of word.upper() for each word in the words list." You're describing the list you want to create, not the step-by-step mechanical process of building it. This isn't just syntactic sugar; it's a fundamentally different way of thinking about the problem that aligns perfectly with how our brains work.

But it gets even better. What if you want to filter the list at the same time? Let's say we only want to include superheroes with long names in our new list. The traditional loop-and-append method gets even clunkier:

superheroes = ["Batman", "Wonder Woman", "Spider-Man", "Hulk"]
long_names = []
for hero in superheroes:
    if len(hero) > 5:
        long_names.append(hero)

# long_names is now ['Batman', 'Wonder Woman', 'Spider-Man']

Again, this works, but we've added more indentation, more logic, more lines. The core idea is getting lost in the ceremony. The list comprehension handles this with effortless grace:

superheroes = ["Batman", "Wonder Woman", "Spider-Man", "Hulk"]
long_names = [hero for hero in superheroes if len(hero) > 5]

The syntax is a natural extension: [expression for item in list if condition]. It's all right there, in one line. The transformation (just taking the hero as-is) and the filter (if len(hero) > 5) are combined into a single, elegant expression. Once you internalize this pattern, you'll see it everywhere. You'll start to view loops that just build up lists as code smells. Why would you ever write five lines of code when one will do the job more clearly and, often, more efficiently? The list comprehension isn't a trick or a shortcut for experts. It is the standard, idiomatic way to perform this task in Python. To ignore it is to willfully write worse code.

The loop of last resort

And then there is the loop of last resort. The one you should hesitate to use, the one that signals you might be doing something fundamentally weird or dangerous. I'm talking about the while loop. For the task of simply iterating through the items of an existing, stable list, the while loop is an abomination. It's a regression to the bad old days of manual bookkeeping, and it throws away all the safety and clarity that the Python for loop provides.

If you see a programmer iterating over a list like this, you have my permission to stage an intervention:

superheroes = ["Batman", "Wonder Woman", "Spider-Man", "Iron Man"]

# An absolutely terrible way to loop over a list.
# Do not write code like this. Ever.
i = 0
while i < len(superheroes):
    hero = superheroes[i]
    print(f"{hero} is on the case!")
    i += 1

What is this monstrosity? We've manually recreated the C-style for loop that we already established was a terrible idea. We have to initialize a counter (i = 0). We have to perform a check on every iteration (i < len(superheroes)). And, most critically, we have to remember to increment the counter (i += 1). If you forget that last line, congratulations, you've just written an infinite loop that will spin forever printing Batman's name. The for loop protects you from this entire class of stupid, self-inflicted bugs. Using a while loop here is like choosing to assemble a car from a pile of parts when a perfectly good, fully-functional car is sitting right next to you, keys in the ignition.

So when is it acceptable? The while loop's territory is not simple iteration, but iteration with complex, stateful conditions. Specifically, it becomes a potential tool when you need to modify the list you are currently looping over. This is a hazardous operation, like performing surgery on a moving train, and the for loop's internal iterator can't handle it. If you try to remove items from a list while iterating over it with a for loop, you'll get bizarre, unpredictable behavior because you're pulling the rug out from under the iterator.

The while loop, by forcing you to manage the index manually, gives you the control needed for this dangerous maneuver. Let's say you want to remove all items from a list that don't meet a certain condition, and you need to do it in-place for memory reasons. This is one of the few legitimate use cases.

tasks = ["answer email", "refactor", "delete code", "meeting", "write docs"]

# Remove any task that is not "delete code"
i = 0
while i < len(tasks):
    if tasks[i] != "delete code":
        tasks.pop(i)
        # IMPORTANT: Do not increment i!
        # The next item has shifted into the current index.
    else:
        i += 1

# tasks is now ['delete code']

Notice the tricky logic here. When we find an item to remove, we pop it from the list. The list shrinks, and the item that was at index i + 1 now slides into the current index i. Because of this, we must not increment our counter. We need the loop to re-evaluate the item at the same index on the next pass. We only increment i when we decide to keep the item and move on. This is complex, error-prone, and hard for the next person to read. One small mistake in your logic and you've either skipped an element or gone out of bounds.

Frankly, even in this case, a list comprehension is almost always the better answer: tasks = [task for task in tasks if task == "delete code"]. It's clearer, safer, and less likely to cause bugs. You should only resort to the in-place while loop modification when you are working with enormous lists where creating a second copy in memory is not feasible. It is a specialist's tool for a rare problem. If you find your hand reaching for while to iterate over a list, your first instinct should be to stop and ask yourself, "Am I absolutely sure there isn't a simpler, safer way?" Because there almost always is.

Comments

No comments yet. Why don’t you start the discussion?

Leave a Reply

Your email address will not be published. Required fields are marked *