Devpuff
Pricing
Log InStart Free
  1. Home
  2. Blog
  3. Python for loops: the complete beginner guide
Learn

Python for loops: the complete beginner guide

A Python for loop walks a sequence directly, not a counter. Here is every form, including the else clause and the mutation trap.

By Max Arthur
Co-Founder & Content Marketer·August 22, 2026·10 Min read
A Python for loop taking each item from a list in turn, with the loop variable shown holding one value per pass

A Python for loop walks through the items of a sequence, one at a time:

items = ["a", "b", "c"]
for x in items:
    print(x)
a
b
c

There is no counter, no length check and no index. x holds each item in turn, and the loop ends when the sequence runs out.

If you have written loops in another language, that is the shift: Python's for is a for-each, not a counting loop. Counting is something you ask for explicitly when you need it. Every output on this page came from Python 3.13 through Pyodide 0.28.3.

The anatomy

for <variable> in <sequence>:
    <body>

Four parts, and three of them are fixed. for and in are keywords. The colon ends the header. The body is indented, and the indentation is what marks it as the body, which is why getting it wrong is a syntax error rather than a style issue.

The variable name is yours to pick. Name it for one item, not for the collection: for user in users reads correctly everywhere it is used inside the loop.

What you can loop over

Anything iterable, which is most things:

for ch in "hi":        # characters of a string
    print(ch)          # h, i

for n in (1, 2):       # a tuple
    print(n)

for k in {"a": 1, "b": 2}:   # a dict gives you its KEYS
    print("key", k)          # key a, key b

That last one catches people out. Looping a dictionary gives you keys, not values and not pairs. For pairs, ask for them:

for k, v in d.items():
    print("item", k, v)      # item a 1, item b 2

.items() yields two-value tuples, and for k, v in ... unpacks each one into two names. .keys() and .values() give you one or the other.

Counting, when you actually need it

range produces numbers, and it is what you use when you need a count rather than the items:

for i in range(3):
    print(i)      # 0, 1, 2

The instinct from other languages is to loop over indices and index back into the list:

for i in range(len(items)):
    print(items[i])

That works and it is rarely what you want, because it can go out of bounds and it is noisier. Loop the list directly. When you need the position and the item, enumerate gives you both, which is covered in how to loop with an index. For the numbers themselves, range explained covers start, stop and step.

break, continue, and the else nobody uses

break leaves the loop immediately. continue skips to the next item.

for n in nums:
    if n < 0:
        continue      # skip negatives
    if n > 100:
        break         # stop entirely
    print(n)

Then there is for ... else, which is real Python and is almost always misread:

for n in [1, 2, 3]:
    if n == 99:
        break
else:
    print("else ran: no break happened")

That prints. Add a break that actually fires and it does not:

for n in [1, 2, 3]:
    if n == 2:
        break
else:
    print("this does not print")

The else runs only if the loop finished without breaking. It is not "otherwise", despite the keyword. Read it as "if no break", and it becomes useful for search loops:

for user in users:
    if user.id == target:
        print("found")
        break
else:
    print("no user with that id")

Without it you need a separate found = False flag. With it the "nothing matched" case has its own place to live.

The trap: changing a list while looping it

This is the single most common way a Python loop goes quietly wrong.

nums = [1, 2, 3, 4]
for n in nums:
    if n % 2 == 0:
        nums.remove(n)
print(nums)     # [1, 3]

That output is correct. Removing the evens from [1, 2, 3, 4] should leave [1, 3], and it did. The code looks fine and passes the test you would write for it.

Now the same code on a different list:

nums = [1, 2, 4, 3]
for n in nums:
    if n % 2 == 0:
        nums.remove(n)
print(nums)     # [1, 4, 3]

4 is still there. Nothing changed except the order of the input.

The reason is that the loop tracks a position, and removing an item shifts everything after it down one. When 2 is removed from position 1, 4 slides into position 1, and the loop has already moved on to position 2. 4 is never examined.

The first example only looked correct because the numbers happened to line up. This is a bug that passes a hand-written test and fails on real data.

Two fixes, both one line:

nums = [n for n in nums if n % 2 != 0]     # build a new list
for n in nums[:]:                          # iterate over a copy
    if n % 2 == 0:
        nums.remove(n)

Both give [1, 3] on either input. The comprehension is the better habit: it does not mutate anything, so the problem cannot arise. nums[:] is a slice copy, and the [:] is easy to miss when reading, which is a small argument against it.

The rule generalises: do not add to or remove from a collection while looping over it. Build a new one instead.

The loop variable outlives the loop

for i in range(3):
    pass
print(i)     # 2

Python has no block scope for loops, so i still exists afterwards, holding its last value. This is occasionally handy and more often a source of confusion, particularly when a later line uses i by accident and gets a stale value rather than an error.

It also means an empty sequence leaves the variable undefined entirely, so code that reads the loop variable afterwards can raise NameError depending on the data.

Some things can only be looped once

A list can be looped over as many times as you like. An iterator cannot:

it = iter(["a", "b"])
list(it)    # ['a', 'b']
list(it)    # []

The second pass is empty. Iterators are consumed as they are read, and once exhausted they stay that way.

This matters because several common things are iterators rather than sequences: zip, map, filter, generator expressions, and an open file. Looping one twice gives you every item the first time and nothing the second, with no error to explain it.

for line in f:      # reads the file
    ...
for line in f:      # nothing, the file is at the end
    ...

If you need more than one pass, materialise it once with list(...) and loop that. If the data is large enough that materialising it is the problem, restructure so one pass does all the work.

The patterns that replace a loop

Many loops are a standard operation written out longhand, and the built-in version is both shorter and clearer about intent:

total = 0
for n in nums:
    total += n
total = sum(nums)

The ones worth knowing, all measured on [1, 2, 3, 4]:

Instead of a loop that Use
Adds everything up sum(nums) gives 10
Checks if any item matches any(n > 3 for n in nums) gives True
Checks if all items match all(n > 0 for n in nums) gives True
Finds the first match next((n for n in nums if n > 2), None) gives 3
Builds a transformed list [n * 2 for n in nums if n % 2 == 0] gives [4, 8]
Finds the biggest by some measure max(items, key=len)

next() with a default deserves attention, because the "find the first match" loop with a break and a flag is one of the most commonly written blocks in Python, and this is a one-line replacement that returns None instead of raising when nothing matches.

A for loop is still the right answer when the body does several things, when it has side effects like writing files, or when the logic is too long to read on one line. Reach for these when the loop's only job is to produce one value.

Nesting, and when to stop

Loops nest, and the inner one runs fully for each pass of the outer one:

for row in grid:
    for cell in row:
        print(cell)

Worth knowing that break only leaves the loop it is in. To exit both, put the pair in a function and return, which is cleaner than the flag variable people usually reach for.

Two levels is normal for a grid. Three is worth a second look, because the work usually belongs in a function or the data wants reshaping.

When a loop does not do what you expect

Four checks that resolve most of it.

Nothing ran at all. The sequence was empty. A range counting the wrong direction produces an empty range with no error, and so does a filtered list where nothing matched. Print the length before the loop.

It ran once when you expected many. You are looping over something that is not the collection you think. Looping a string gives characters; looping a dictionary gives keys; looping a single object that happens to be iterable gives its parts.

It skipped items. You are modifying the collection inside the loop, covered above. This is the one that produces correct-looking output on some inputs.

The body did not run for every branch. Indentation. A line that should be inside the loop sitting one level out runs once, after the loop, with the loop variable holding its final value. This is the most common Python-specific loop bug and it produces no error, because both versions are valid code.

That last one is worth a habit: when a loop's result looks like it only processed the final item, check the indentation of the line producing the result before checking the logic.

Looping without an index at all

The constructs above cover positions and pairs, and two more remove the loop entirely for common shapes.

Dictionary and set comprehensions work like list comprehensions:

lengths = {word: len(word) for word in words}
initials = {word[0] for word in words}

Unpacking handles fixed-size sequences without iterating:

first, second = pair
head, *rest = items

*rest collects everything left over into a list, which is the tidy way to separate a first item from the remainder without slicing twice.

None of these replace a for loop for real work. They are worth knowing because a surprising share of loops in beginner code exist only to build a collection or pull two values apart, and both have shorter, clearer forms.

Quick reference

for item in items:            # each item
for i in range(n):            # 0 to n-1
for i, item in enumerate(items):   # position and item
for k, v in d.items():        # dictionary pairs
for a, b in zip(xs, ys):      # two sequences together
for line in open("f.txt"):    # lines of a file

break        # leave the loop
continue     # skip to the next item
else:        # runs only if no break happened

Ready to write loops against real data? Start with the Python track, or read zip() for looping over two lists for the next construct most beginners need.

Keep reading

More from the blog

Two arrays combined by spread into a new array, beside push spreading into an existing one and overflowing the stack
September 1, 2026·9 min readLearn

How to merge two arrays in JavaScript

To merge arrays in JavaScript, spread and concat both work. push(...arr) throws a RangeError at 200,000 items, which is why the choice matters.

Read more
The same object iterated with Object.keys and with for-in, where for-in returns an extra inherited key
August 31, 2026·9 min readLearn

How to loop through an object in JavaScript

To loop through an object in JavaScript, for...in walks inherited properties too. And integer-like keys come out first, whatever order you wrote.

Read more
{ }
✦

Ready to write some code?

Put this into practice - start your first free lesson. No setup, no credit card.

Start learning free
Devpuff

Learn to code by doing. One tiny, playful lesson at a time.

Learn
ProgramsCoursesPricing
Company
AboutBlogResourcesAffiliates
Support
Help CenterContactStatus
Programs
Frontend DevelopmentFull-Stack DevelopmentPython Developer
Courses
Advanced ReactAsync JavaScriptAsync PythonCSS BasicsCSS LayoutDSA Basics
Learn to Code
Learn JavaScriptLearn PythonLearn SQLLearn HTMLLearn CSSLearn ReactBrowse All Topics
Platform Comparisons
Devpuff vs CodecademyDevpuff vs MimoDevpuff vs Sololearn
© 2026 Devpuff. All rights reserved.Privacy PolicyTerms and ConditionsCookies PolicyRefund Policy
Devpuff vs freeCodeCamp
Read All Comparisons