Python • Control Flow

range() — Use range() correctly (start, stop, step) and avoid off-by-one errors

Master Python's range() function to write precise, bug-free loops every time.

Code braces with terminal and markup symbols representing Python programming.
Inside this lesson 10 sections 5 questions 7 figures

SECTION 1

Historical Context & Motivation

Before modern programming languages existed, early computer scientists had to manually manage how many times a loop would repeat. They would set up counter variables, increment them by hand, and check boundary conditions at each step. This process was tedious and extremely error-prone. The idea of a built-in function that generates a clean sequence of numbers emerged as languages evolved, and Python's range() function is one of the most elegant solutions to this problem.

1972 • C Language and Manual Loops

The C programming language introduced the classic for(i=0; i<n; i++) loop structure, requiring programmers to manage start, stop, and step values manually.

1991 • Python 1.0 — range() Is Born

Guido van Rossum released Python with a built-in range() function that returned a full list of integers, simplifying loop construction dramatically.

2001 • Python 2.2 — xrange() Appears

To save memory, Python 2 introduced xrange(), a lazy version that generated values one at a time instead of building an entire list in memory.

2008 • Python 3.0 — range() Becomes Lazy

Python 3 replaced the old list-based range() with the memory-efficient lazy version. The old xrange() was removed, and range() became the standard.

Despite how common range() is in everyday Python code, it remains a source of subtle bugs. The most infamous of these is the off-by-one error — getting one too many or one too few iterations because you forgot that range() excludes its stop value. Understanding exactly how range() works is essential for writing correct, predictable loops.

SECTION 2

Core Principles & Definitions

The range() function produces an immutable sequence of integers. It accepts one, two, or three arguments: range(stop), range(start, stop), or range(start, stop, step). Grasping these three parameters — and especially the rule that the stop value is exclusive — is the key to avoiding off-by-one errors.

1

Start (Inclusive)

The first value in the sequence. Defaults to 0 when only one argument is given. The sequence includes this value.
2

Stop (Exclusive)

The boundary where the sequence ends. The sequence goes up to but never includes the stop value. This is the most common source of off-by-one errors.
3

Step (Increment)

The difference between consecutive values. Defaults to 1. Can be negative for counting backward, but must never be zero.
4

Half-Open Interval

Python's range uses a half-open interval written [start, stop). This mathematical convention means start is included and stop is excluded, which pairs naturally with zero-based indexing.
✦

KEY TAKEAWAY

Think of range() like a fence. If you want a fence with 5 posts starting at post 0, you write range(0, 5) — the 5 tells Python where to stop building, not the label of the last post. The last post is number 4, just like the last index in a 5-element list.

SECTION 3

Visual Explanation — The Number Line

The following diagram shows exactly which values range() produces for three common calling patterns. Notice how the stop value is always marked with an open circle (excluded), while the start value is a filled circle (included). This visual pattern is your best defense against off-by-one errors.

Three Ways to Call range()range(5)Produces: 0, 1, 2, 3, 4012345(excluded)range(2, 7)Produces: 2, 3, 4, 5, 601234567range(1, 10, 2)Produces: 1, 3, 5, 7, 91357910+2+2+2+2IncludedExcluded (stop)
Filled circles represent values that are included in the sequence. Open red circles mark the excluded stop value. Dashed circles show numbers that exist on the number line but fall before the start value.

The visual pattern is consistent across all three forms. In the first row, range(5) starts at 0 (the default) and counts up to but not including 5. In the second row, range(2, 7) shifts the starting point to 2 while still excluding 7. In the third row, range(1, 10, 2) skips every other number by using a step of 2, producing only odd values from 1 through 9. The stop value of 10 is never reached — it is merely the boundary.

SECTION 4

How range() Generates Values

Under the hood, range() doesn't store every number in memory. Instead, it calculates each value on demand using a simple formula. Understanding this formula helps you predict exactly how many values a range will produce and what the last value will be.

NTH VALUE FORMULA

value(i)=start+i×stepvalue(i) = start + i × step

Where i is the zero-based index (0, 1, 2, …). The sequence continues as long as value(i) < stop (for positive step) or value(i) > stop (for negative step).

LENGTH FORMULA

length=max(0,⌈(stop−start)/step⌉)length = max(0, ⌈(stop − start) / step⌉)

The ceiling function ⌈ ⌉ rounds up to the nearest integer. If (stop − start) and step have opposite signs, the result is 0, meaning the range is empty.

Let's apply the length formula to range(2, 11, 3). We compute (11 − 2) / 3 = 9 / 3 = 3. The ceiling of 3 is 3, so this range produces exactly 3 values. Using the nth-value formula: value(0) = 2 + 0 × 3 = 2, value(1) = 2 + 1 × 3 = 5, value(2) = 2 + 2 × 3 = 8. Notice that 11 itself is never included because 2 + 3 × 3 = 11 meets the stop boundary.

💡

Why Exclusive Stop?

The exclusive stop convention aligns with zero-based indexing. A list with n items has valid indices from 0 to n − 1. Writing for i in range(n) gives you exactly those indices. It also makes splitting ranges clean: range(0, 5) and range(5, 10) together cover 0–9 with no overlap and no gap.

SECTION 5

Common Off-by-One Errors & How to Avoid Them

An off-by-one error (sometimes abbreviated OBOE) occurs when your loop runs one iteration too many or too few. These bugs are notoriously hard to spot because the code looks almost correct. The diagram below illustrates the most common mistakes side by side with their fixes.

Common Off-by-One Scenarios❌ BUGGoal: print 1 through 5for i in range(5): print(i)Output: 0, 1, 2, 3, 4Problem: starts at 0, ends at 4✅ FIXGoal: print 1 through 5for i in range(1, 6): print(i)Output: 1, 2, 3, 4, 5Fix: start=1, stop=6 (5+1)❌ BUGGoal: access all list itemsfor i in range(1, len(lst)): print(lst[i])Skips the first element (index 0)Problem: starts at 1, not 0✅ FIXGoal: access all list itemsfor i in range(len(lst)): print(lst[i])Covers indices 0 through len−1Fix: omit start (defaults to 0)Tip: Always ask yourself — does this range start and end where I expect?
Two of the most frequent off-by-one bugs: forgetting that range(n) starts at 0, and accidentally skipping index 0 by writing range(1, len(lst)) instead of range(len(lst)).
GoalIncorrect range()Correct range()Why
Count 1 to nrange(n)range(1, n + 1)Default start is 0; stop must be n + 1 to include n
Count down from 5 to 1range(5, 0)range(5, 0, -1)Without a negative step, range cannot count backward
Even numbers 2–10range(2, 10, 2)range(2, 11, 2)Stop at 10 excludes 10; use 11 to include it
Iterate a list of length nrange(0, n + 1)range(n)n + 1 causes IndexError; range(n) naturally stops at n − 1

Common off-by-one scenarios and their fixes

SECTION 6

Worked Example — Summing Multiples of 3

Let's walk through a real problem step by step. The task is to compute the sum of all multiples of 3 from 3 to 30, inclusive.

Sum of Multiples of 3 from 3 to 30

  1. 1

    Step 1 — Identify the Start Value

    The first multiple of 3 we want is 3. So we set start = 3.
    start = 3
  2. 2

    Step 2 — Determine the Stop Value

    We want to include 30 in our sum. Since range() excludes its stop, we need to set stop to one step beyond 30. Because our step is 3, we could use 31 (or any value from 31 to 33). The simplest and most readable choice is 30 + 1 = 31.
    stop = 31
  3. 3

    Step 3 — Set the Step Value

    We want every third number, so step = 3. Our range call becomes range(3, 31, 3).
    range(3, 31, 3)
  4. 4

    Step 4 — Verify the Sequence

    Let's convert to a list to check: list(range(3, 31, 3)) produces [3, 6, 9, 12, 15, 18, 21, 24, 27, 30]. That's 10 values, and 30 is correctly included. If we had used stop = 30 instead of 31, the list would end at 27 — a classic off-by-one error.
    10 values from 3 to 30 ✓
  5. 5

    Step 5 — Write the Complete Code

    Now we write the full solution: total = sum(range(3, 31, 3)). The built-in sum() function adds all values in the range. The result is 3 + 6 + 9 + … + 30 = 165.
    total = 165

SECTION 7

range() vs. Other Iteration Tools

Python offers several ways to iterate, and range() is not always the best choice. Understanding when to use range() versus direct iteration or enumerate() will make your code both cleaner and less prone to errors.

ApproachBest ForLimitation
for i in range(len(lst))When you need the index number itself — e.g., to modify elements in place or compare adjacent itemsVerbose; easy to introduce off-by-one errors with start/stop
for item in lstWhen you only need the values — reading data, printing, accumulatingNo access to the index without extra code
for i, item in enumerate(lst)When you need both the index and the value simultaneouslySlightly more syntax to learn; start parameter can confuse beginners
while loop with counterWhen the number of iterations is unknown or depends on a runtime conditionMust manually manage the counter; risk of infinite loops

Choosing the right iteration tool

✦

KEY TAKEAWAY

A good rule of thumb: if you find yourself writing for i in range(len(lst)) and only ever using lst[i] inside the loop, you probably don't need range() at all. Use for item in lst instead — it's cleaner, faster, and eliminates any chance of an off-by-one error.

SECTION 8

Connection to Advanced Iteration

As you grow as a Python programmer, you'll encounter concepts that build directly on the ideas behind range(). Generators, list comprehensions, and the itertools library all rely on the same principle of producing values lazily and using half-open intervals.

ConceptConnection to range()When You'll Learn It
List ComprehensionsOften used with range() inside brackets: [x**2 for x in range(10)]Usually introduced alongside for loops in introductory Python
SlicingUses the exact same start:stop:step pattern as range(), and the stop is also exclusiveWhen working with lists and strings in depth
Generatorsrange() is itself a lazy iterable; generators extend this idea to custom sequences using yieldIntermediate Python or AP Computer Science A
NumPy arange() / linspace()arange() works like range() but with floats; linspace() includes the endpoint by defaultData science and scientific computing courses

How range() concepts extend to advanced Python

One especially important connection is slicing. When you write my_list[1:5], you get elements at indices 1, 2, 3, and 4 — exactly the same values as range(1, 5). Mastering range() now gives you a head start on slicing, which you'll use constantly when manipulating strings and lists.

SECTION 9

Practice Problems

Question 1: CONCEPTUAL

Explain why range(5) produces 5 values but does not include the number 5 itself. What design principle makes this useful when iterating over a list?
Show answer
The range() function uses a half-open interval [0, 5), meaning it includes 0 and goes up to but excludes 5. This produces the values 0, 1, 2, 3, 4 — exactly five values. This design aligns perfectly with zero-based indexing: a list of length 5 has valid indices 0 through 4. By writing for i in range(len(my_list)), you automatically get every valid index without risking an IndexError. The exclusive stop also allows clean range splitting — range(0, 5) and range(5, 10) cover 0–9 with no overlap.

Question 2: BASIC CALCULATION

What output does list(range(3, 12, 2)) produce? How many elements are in the result?
Show answer
Starting at 3, the function adds 2 each time: 3, 5, 7, 9, 11. The next value would be 13, which is ≥ 12 (the stop), so the sequence ends. The output is [3, 5, 7, 9, 11], which contains 5 elements. Using the length formula: ⌈(12 − 3) / 2⌉ = ⌈9 / 2⌉ = ⌈4.5⌉ = 5, confirming our answer.

Question 3: INTERMEDIATE

Write a range() call that produces the countdown: 10, 8, 6, 4, 2. Then explain what would happen if you forgot the step argument.
Show answer
The correct call is range(10, 1, -2). This starts at 10, decrements by 2 each time, and stops before reaching 1. The values produced are 10, 8, 6, 4, 2. If you wrote range(10, 1) without the -2 step, the default step is +1. Since 10 is already greater than 1 and the step is positive, Python recognizes the range is empty and produces no values at all — no error, just an empty sequence.

Question 4: APPLIED

You have a list of 20 student scores and you want to print every third score starting from the second one (index 1). Write a for loop using range() that does this correctly. What are the indices your loop visits?
Show answer
The loop should be: for i in range(1, 20, 3): print(scores[i]). Starting at index 1 with a step of 3, the visited indices are 1, 4, 7, 10, 13, 16, 19. The next would be 22, which exceeds the stop of 20, so the loop ends. Notice that using len(scores) instead of the hardcoded 20 would make the code more flexible: range(1, len(scores), 3). This avoids an off-by-one risk if the list size ever changes.

Question 5: CRITICAL THINKING

A classmate writes for i in range(1, n): total += i to sum all integers from 1 to n. They test it with n = 5 and get 10 instead of the expected 15. Diagnose the bug, fix the code, and explain a mental model that would prevent this mistake in the future.
Show answer
The bug is that range(1, n) stops before n, so with n = 5 it produces 1, 2, 3, 4 (summing to 10, not 15). The fix is range(1, n + 1), which includes n. A reliable mental model: whenever your problem says 'from A to B inclusive,' always write range(A, B + 1). Think of the stop parameter as the 'first number NOT in the list.' If you want B in the list, the first number not in the list is B + 1. This 'add one to include' rule prevents the most common off-by-one error in Python.

SUMMARY

Lesson Summary

Summary

Python's range() function generates an immutable sequence of integers using three parameters: start (inclusive), stop (exclusive), and step (increment). The single most important rule is that the stop value is never included in the output — this half-open interval convention aligns with zero-based indexing and makes range(len(lst)) produce exactly the right indices for any list.

To avoid off-by-one errors, remember the 'add one to include' rule: if you want the number B in your sequence, set the stop to B + 1. For counting backward, always provide a negative step. When you don't need indices at all, prefer direct iteration (for item in lst) or enumerate() to write cleaner, less error-prone code. These same start/stop/step patterns will reappear in list slicing, comprehensions, and advanced libraries like NumPy.

Varsity Tutors • Python • range() — Use range() correctly (start, stop, step) and avoid off-by-one errors