Python Part I · Module 10 · Loops
Counting With range()
Every collection so far was something you had: a list you wrote, a log you read, a dictionary you built. A seed sweep is not like that. You do not have a hundred seeds — you have a starting value and a count, and the hundred seeds follow from them. range() is the thing that turns a rule into a collection a loop can walk. It takes about a minute to learn and has exactly one feature that trips people up permanently, which is that it stops before the number you gave it. That is the right design, and this chapter shows why by demonstration rather than asking you to remember it.
Foundation15 min readPythonPart IFundamentalsLoopsrange
Part I · Module 10 · Chapter 10.4 · Counting With range()
1. A Collection You Do Not Write Down
Running one test at ten seeds means ten values that you should not type:
for seed in range(10):
print(seed)0
1
2
3
4
5
6
7
8
9Ten passes, from 0 to 9. range(10) is a collection in every sense that matters to a loop — it supplies items one at a time, in order, and the loop is the same loop.
The thing to notice immediately: ten values, and the last one is 9.
2. Why the End Is Excluded
range(n) produces n values starting at zero, so the final value is n - 1. Stated as a rule it is forgettable. It is easier to keep hold of through its three consequences, each of which is the reason it was designed this way.
The count is the argument. You never work out how many passes there will be:
print(len(range(10)))
print(len(range(3)))
print(len(range(0)))10
3
0range(10) means ten of them. If the end were included, range(10) would give eleven values and every repeat count would need a - 1.
Ranges tile. Two adjacent ranges cover a span exactly once, with no overlap and no gap:
print(list(range(0, 3)))
print(list(range(3, 6)))
print(len(range(0, 3)), "+", len(range(3, 6)), "=", len(range(0, 6)))[0, 1, 2]
[3, 4, 5]
3 + 3 = 6The first ends where the second begins, and 3 appears in exactly one of them. Splitting a sweep of 600 seeds across four machines is range(0, 150), range(150, 300), range(300, 450), range(450, 600) — written straight down, with no fencepost arithmetic anywhere.
It matches indexing. range(len(x)) is exactly the valid positions of x:
tests = ["smoke", "reset", "burst"]
print(list(range(len(tests))))
print(tests[0], tests[2])[0, 1, 2]
smoke burstThree items, positions 0 to 2, and no - 1 written anywhere. This is the same half-open convention that made slicing work in Chapter 4.5, where s[0:3] took three characters.
3. Starting Somewhere Else
With two arguments, the first is where to start:
base = 4210
for seed in range(base, base + 3):
print("seed", seed)seed 4210
seed 4211
seed 4212The starting value can be a name, so a sweep is parameterised by where it begins and how many it wants. Written out literally it reads the same:
for seed in range(4210, 4213):
print("running axi_smoke with seed", seed)running axi_smoke with seed 4210
running axi_smoke with seed 4211
running axi_smoke with seed 4212Three seeds. The end is 4213 because the end is excluded — and the count is 4213 - 4210 = 3, which is the subtraction working out because of the convention rather than despite it.
4. Counting by Something Other Than One
A third argument is the step:
print(list(range(0, 10, 2)))
print(list(range(1, 10, 3)))[0, 2, 4, 6, 8]
[1, 4, 7]A negative step counts down, and then the start is the high value:
print(list(range(3, 0, -1)))
print(list(range(10, 0, -2)))[3, 2, 1]
[10, 8, 6, 4, 2]for n in range(3, 0, -1):
print("retrying, attempts left:", n)
print("giving up")retrying, attempts left: 3
retrying, attempts left: 2
retrying, attempts left: 1
giving upThe end is still excluded, which is why range(3, 0, -1) stops at 1 and not 0.
A range that cannot get from start to end is simply empty — not an error:
print(list(range(5, 1)))
print(len(range(5, 1)))[]
0for n in range(5, 1):
print("this never prints")
print("the loop ran zero times")the loop ran zero timesThat is Chapter 10.1's zero-pass case: an empty collection runs the body zero times and raises nothing. It is also a common silent bug — a sweep whose bounds are the wrong way round does nothing at all, quietly.
A step of zero is an error, because it could never terminate:
print(list(range(0, 5, 0)))ValueError: range() arg 3 must not be zero5. A range Does Not Build Anything
range() does not produce a list. It produces a range object, which generates values on demand:
print(range(3))
print(type(range(3)))
print(list(range(3)))range(0, 3)
<class 'range'>
[0, 1, 2]Printing a range shows you its recipe, not its contents — a frequent source of confusion when debugging. list() materialises it when you actually want to look.
The reason this matters is cost. A range of a million integers occupies the same tiny amount of memory as a range of three, because neither stores any integers at all:
r = range(1000000)
print(r)
print(len(r))
print(999999 in r)
print(1000000 in r)
print(r[0], r[-1], r[10])range(0, 1000000)
1000000
True
False
0 999999 10Length, membership and indexing all answer immediately — by arithmetic, not by searching. So for seed in range(1000000): is not a program that first builds a million-element list; it is a loop that counts.
6. range Counts, So It Needs Integers
print(list(range(2.5)))TypeError: 'float' object cannot be interpreted as an integerEven a float with nothing after the decimal point is refused:
print(list(range(3.0)))TypeError: 'float' object cannot be interpreted as an integerrange counts whole steps, and there is no sensible number of steps between 0 and 2.5. If a count arrives as a float — from a division, typically — convert it deliberately with int(), which Chapter 2.11 covered, so that the rounding is something you chose rather than something that happened.
7. When Not to Use range()
This is the most common misuse of range(), and it is worth stating plainly because it is usually taught as the way to loop:
tests = ["smoke", "reset", "burst"]
for i in range(len(tests)):
print(i, tests[i])0 smoke
1 reset
2 burstIt works. It is also a longer, slower, more fragile way of writing this:
tests = ["smoke", "reset", "burst"]
for test in tests:
print(test)smoke
reset
burstThe first version introduces an index you did not want, does a lookup per pass, and can go out of bounds if the list changes. Chapter 10.1's rule applies: loop over the collection, not over the numbers that happen to address it.
range(len(...)) earns its place only when you genuinely need the position as data — reporting "failure 3 of 12", or pairing two collections by position. Even then there is a better tool: enumerate() gives you the index and the item together, and Chapter 10.9 covers it. Treat for i in range(len(x)): as a sign that one of those two is what you actually meant.
A related off-by-one is worth seeing, since it is the usual way to "skip the header":
rows = ["name seed status", "axi_smoke 4210 PASS", "axi_burst 4211 FAIL"]
for i in range(1, len(rows)):
print(rows[i])axi_smoke 4210 PASS
axi_burst 4211 FAILThat is correct — range(1, 3) gives 1 and 2. It is still clearer as a slice, because Chapter 5.2 gave you one: for row in rows[1:]: says every row after the first without arithmetic.
8. The Boundary Table
| Written | Produces | Count | Last value |
|---|---|---|---|
range(0) | nothing | 0 | — |
range(3) | 0, 1, 2 | 3 | 2 |
range(1, 4) | 1, 2, 3 | 3 | 3 |
range(0, 10, 2) | 0, 2, 4, 6, 8 | 5 | 8 |
range(3, 0, -1) | 3, 2, 1 | 3 | 1 |
range(5, 1) | nothing | 0 | — |
range(0, 5, 0) | — | — | ValueError |
range(2.5) | — | — | TypeError |
The count of range(a, b) is b - a; the count of range(a, b, s) is that span divided by the step, rounded up.
9. Predict, Run, Observe
# A
print(list(range(4)))
# B
print(list(range(2, 7, 2)))
# C
total = 0
for n in range(1, 5):
total = total + n
print("total:", total)
# D
count = 0
for n in range(10, 0, -3):
count = count + 1
print("passes:", count, "last:", n)The verified output:
[0, 1, 2, 3]
[2, 4, 6]
total: 10
passes: 4 last: 1A — four values beginning at zero.
B — starts at 2, steps by 2, stops before 7, so 6 is the last that fits.
C — 1 + 2 + 3 + 4, and 5 is excluded. Had the end been inclusive this would be 15.
D — 10, 7, 4, 1 — four passes, and the loop variable survives holding the last value, which is Chapter 10.1's Section 4.
10. Exercises
Exercise 1 — write the range. Give the range(...) call for each: (a) the ten seeds 4210–4219 (b) the numbers 1 to 100 inclusive (c) every second seed from 0 to 98 inclusive (d) 5 down to 1 (e) nothing at all, using two arguments.
Exercise 2 — predict the count. Without running, give the number of passes for range(7), range(2, 9), range(0, 20, 5), range(1, 2), range(4, 4), range(9, 2, -2). Then verify all six with len().
Exercise 3 — the tiling property. Split range(0, 600) across four machines as four adjacent ranges. Print each one's first and last value, and confirm the four lengths sum to 600 with no value appearing twice.
Exercise 4 — the empty sweep. Write a loop over range(4210, 4200) that would print on every pass. Explain in two sentences why nothing printed and why that is more dangerous than an error.
Exercise 5 — remove the index. Rewrite each without range(len(...)), or say why it cannot be: (a) printing every test name (b) printing 1. smoke, 2. reset with a running number (c) printing every row of a table after the header.
Exercise 6 — lazy or not. Predict the output of print(range(5)) and of print(list(range(5))), then verify. In one sentence, say what the difference tells you about what range(5) is.
Exercise 7 — the two errors. Produce both range() errors deliberately and record the exact messages. For each, say in one sentence why Python refuses rather than guessing.
Exercise 8 — countdown. Print a countdown from 5 to 1 and then go. Then change it to count down to 0 inclusive, and say which argument you changed and why.
11. Summary
range(n)producesnvalues starting at zero, so the last isn - 1.- The end is excluded for three reasons: the count is the argument, adjacent ranges tile with no overlap or gap, and
range(len(x))is exactly the valid positions ofx. - Half-open is the same convention as slicing — learn it once, use it in both places.
range(a, b)starts ata, and its count isb - a.- A third argument is the step; a negative step counts down from the higher start.
- A range that cannot reach its end is empty, runs the body zero times, and raises nothing — a silent bug when the bounds are reversed.
- A step of zero is a
ValueError, and a float argument is aTypeError. - A range builds nothing. It prints as its recipe, answers
len()andinby arithmetic, and a million-element range costs no more than a three-element one. - Do not write
for i in range(len(x)):to visit items — loop the collection directly, or useenumerate()when you truly need the position.
The next chapter covers the other kind of loop: the one for when you do not know how many passes there will be.
Where this fits
Part of the Python curriculum.
