VIEW THIS AS

Auto mode follows the Route Engine until you choose a viewpoint.

YOU ARE HERE

ROUTE CHECK

CONNECTED TO

WHAT NEXT

Use the canonical route for this room, or HELP if you are unsure.

The Core Aim of Education | How to Catch an Off-by-One Error Before a Python Loop Skips an Item

A student writes a five-line program to print every member of a reading list. It prints the first three names, quietly skips the last, and still runs without an error. The code looks tidy. The bug lives at the boundary.

An off-by-one error happens when a loop repeats one time too few or one time too many because its starting point, stopping point or index boundary is wrong. In Python, range(start, stop) includes start but excludes stop. That tiny rule is why range(1, 5) produces 1, 2, 3 and 4—not 5.

Did you know that debugging a loop often begins with a pencil, not a computer? Before asking why the code behaves strangely, write down the first value it should visit, the last value it should visit, and how many visits there should be. Then compare those expectations with an actual trace.

Quick diagnosis: what do you expect the loop to do?

Suppose a club stores four book titles in a list. A beginner writes range(1, len(books)) because the books appear to be ‘numbered 1 to 4’. It produces the indices 1, 2 and 3. There are only three visits. Index 0—the first book—is never reached. The mistake is not in the title data; it is in confusing human numbering with zero-based indexing.

Use these three diagnostic questions whenever a loop misbehaves: What is the first valid index? What is the last valid index? Is the stop boundary included or excluded? For a list of length 4, valid indices are 0, 1, 2 and 3. There is no valid index 4.

Worked example: one missing book and one extra step

The following list and snippets are invented teaching examples. They do not represent anyone’s actual school data.

books = ["Atlas", "Orbit", "River", "Forest"]

# Bug A: skips the first item
for i in range(1, len(books)):
    print(books[i])

# Bug B: goes one index too far
for i in range(len(books) + 1):
    print(books[i])

Bug A visits indices 1, 2 and 3, so it prints Orbit, River and Forest while omitting Atlas. Bug B visits 0, 1, 2, 3 and 4. The first four look correct, but books[4] is outside the list; accessing it raises IndexError. Both bugs are boundary mistakes, but only one is guaranteed to make the program stop with an exception in this example.

Trace the loop instead of guessing

Build a small table with the columns iteration, i, item and valid?. For the first faulty loop, record (1, 1, Orbit, yes), (2, 2, River, yes) and (3, 3, Forest, yes). The missing row for i = 0 becomes visible without executing anything. For the second, the fifth row is (5, 4, no item, no). A trace makes the hidden starting and stopping assumptions concrete.

The ordinary index-based correction is range(len(books)), which yields exactly 0, 1, 2 and 3. However, if the job only needs each title—not its index—the clearer Python solution is to iterate directly over the list:

for book in books:
    print(book)

# When both number and title matter:
for number, book in enumerate(books, start=1):
    print(number, book)

The second version displays reader-friendly labels 1 to 4 while Python manages list traversal. It separates display numbering from internal indexing, so a child’s instinct to begin counting at one no longer causes the loop error.

Why it happens: count, position and endpoint are three different things

A four-item list has four items but its last zero-based position is three. The expression len(books) reports the count, 4. The expression books[3] refers to the last element. And the stop value in range(0, 4) is an excluded boundary, not a fifth item. Understanding these roles is more reliable than memorising +1 or −1 patches.

Be especially careful when rewriting a mathematical inclusive interval as a Python range. If a task says ‘print the whole numbers from 3 through 7’, then range(3, 8) is the natural ascending construction. If it says ‘print indices of every value in a five-item list’, use range(5). These are different questions even though the numbers appear similar.

A second worked case: inclusive days versus excluded list boundaries

Imagine a learner wants the day labels 1, 2, 3, 4 and 5 for a five-day revision plan. The intended labels are 1 through 5, so range(1, 6) works. But if the learner stores five scores in a list, the indices are 0 through 4. range(1, 6) would skip score 0 and eventually try the invalid score 5. The same human story—five days—requires different loop boundaries for labels and list positions.

Guided practice: three boundary checks

  • Predict the output of list(range(2, 7)): it is [2, 3, 4, 5, 6]. Count five values; 7 is excluded.
  • Predict the output of list(range(0)): it is [], an empty list. The loop body executes zero times.
  • For a list of 3 items, identify every safe index: 0, 1 and 2. range(len(items)) covers all three; range(len(items) + 1) tries index 3 as well.

After each check, explain not merely what happened but which boundary caused it. This explanation is transferable to slices, tables, strings and later programming languages whose indexing conventions may differ.

Independent practice: build a boundary test suite

Write a tiny function that prints every element of a list. Test it with an empty list, a one-item list and a four-item list. Record the expected output before running the function. With direct iteration, the empty list prints nothing, the one-item list prints its only value, and the four-item list prints four values. For an index-based version, check the first and last expected index. A test that only uses a four-item list may miss some boundary errors; small and empty cases are excellent probes.

A useful next challenge is to test a reversed range such as range(5, 1, -1). It yields 5, 4, 3, 2, stopping before 1. The stop value remains excluded even though the step is negative. Do not transfer the positive-range rule mechanically without checking the step’s direction.

A troubleshooting checklist for parents, teachers and students

  • State what each item represents: a label, an index, a count or a value.
  • Write the expected first and last visits, not just the total count.
  • Predict the number of iterations, then trace actual indices.
  • Test an empty case, a one-item case and a normal case.
  • Prefer direct iteration or enumerate when manually managed indices are unnecessary.
  • Keep a corrected example as a reference for the next unfamiliar loop.

What this does—and does not—prove

Passing a few tests increases confidence in the loop, but it does not prove all code paths are correct. A nested loop, list modified during iteration, conditional break or mismatched input lengths may require a more detailed trace. The lesson is to ask a precise question about the boundary before changing numbers at random.

Frequently asked questions

Why does range(5) not include 5?

Python defines 5 as the excluded stopping boundary. It generates 0 through 4, which is exactly five values. That definition supports convenient zero-based list indexing.

Should beginners always use len and an index?

No. If the program just needs each value, direct iteration is often simpler. Use explicit positions when the position itself matters, or enumerate when you need both an index or display number and the value.

Is an off-by-one error always a crash?

No. Omitting the first or last item can produce plausible-looking but incomplete output. That is why a trace and boundary tests matter even when the program runs.

Further reading and the learner’s finish line

The official Python range documentation explains the type and its inclusive-start, exclusive-stop rule. Compare the habit of checking a program against a hand-worked test case in the Core Aim spreadsheet-testing guide, then visit How Learning Works and the immutable eduKate mathematical reasoning reference.

A capable programmer does not depend on luck at the last item. They can name the boundary, test it and explain why every intended value was visited exactly once.