The warehouse has aisles numbered one through fifty. If I asked you to build a loop that processed every aisle, what would you write first?
I'd feel a strong urge to write [1, 2, 3, ...] out by hand and immediately know I was doing something wrong.
You'd be right to feel that. Python has range() for exactly this. You've used it before — for i in range(10) — but I want to peel it apart today because most people treat it as loop syntax and miss what it actually is.
I always thought of range() as part of the for statement. Like, the two come as a pair.
Range is a standalone object. It knows a start, a stop, and a step — and it calculates each number on demand without storing them all at once. A range(1, 1_000_000) uses roughly the same memory as range(1, 10). Three forms:
print(list(range(5))) # [0, 1, 2, 3, 4]
print(list(range(1, 6))) # [1, 2, 3, 4, 5]
print(list(range(0, 10, 2))) # [0, 2, 4, 6, 8]The list() call — you're converting it to see it? What is it before that?
A range object. Not a list. Think of it as the warehouse knowing "aisles start at 1, go up to 50, step by 1" — you don't need fifty index cards in a drawer. You just need the rule. The loop or the conversion applies the rule when it needs actual numbers.
That's much more efficient for large sequences. If I'm generating a batch of SKUs — WH-001 through WH-500 — I don't need all five hundred strings sitting in memory at once.
Exactly. And SKU generation is the real use case today. Let's say Diane needs a fresh batch of warehouse codes: prefix "WH", starting at 1, count of 5. What would range() look like for that?
I want exactly count numbers starting at start. That's range(start, start + count) — I compute the stop from what I actually want rather than guessing the endpoint.
That habit will save you from off-by-one bugs. range(start, count) by mistake gives you the wrong numbers if start is nonzero. range(start, start + count) always gives you exactly count iterations regardless of where you start. Let's also talk formatting. The raw number from range needs zero-padding:
# Without padding:
for i in range(1, 4):
print(f"WH-{i}")
# WH-1, WH-2, WH-3
# With format spec:
for i in range(1, 4):
print(f"WH-{i:03d}")
# WH-001, WH-002, WH-003{i:03d} — zero-pad to three digits. I know str(i).zfill(3) does the same but keeping it in the f-string is cleaner.
Both work. Now write the whole function before I show you anything:
generate_skus(prefix, start, count) — range from start to start + count, format each i with {prefix}-{i:03d}, collect into a list.
def generate_skus(prefix: str, start: int, count: int) -> list[str]:
skus = []
for i in range(start, start + count):
skus.append(f"{prefix}-{i:03d}")
return skusRight on the first attempt. And you computed the stop yourself — that's the key move. Let me show you the idiomatic one-liner:
def generate_skus(prefix: str, start: int, count: int) -> list[str]:
return [f"{prefix}-{i:03d}" for i in range(start, start + count)]A list comprehension with range inside. Range doesn't care whether it's in a for loop or a comprehension — it just hands out numbers when asked. Same logic, shorter form.
Both versions are correct. The explicit loop is easier to debug — you can add a print inside it. The comprehension is cleaner in production code once you're confident in the logic.
One thing I want to clarify — is range inclusive on both ends? range(1, 50) — does that include 50?
Start-inclusive, stop-exclusive. Always. range(1, 50) gives you 1 through 49. Fifty aisles means range(1, 51). This matches Python slices: my_list[1:5] gives indices 1 through 4. Same rule, consistent across the language.
So "aisles 1 to 50" in English includes aisle 50, but range(1, 50) doesn't. That's the off-by-one error you mentioned.
It has claimed every Python developer I know at least once. range(start, start + count) is the defensive form — you never have to think about the endpoint, just what you actually want.
What about floats? If I needed every half-aisle mark — 1.0, 1.5, 2.0?
Range is integers only. range(0, 5, 0.5) raises a TypeError. For float sequences you'd use a list comprehension with division, or numpy.linspace — but that's a different tool for a different day. For SKU indices, batch IDs, loop counters — integers are always what you want.
Integers are the warehouse use case anyway. SKU numbers, aisle counts, order batch IDs — all whole numbers.
Exactly. And notice what you've built: a clean, testable function. You give it a prefix, a start, and a count — it returns a predictable list. The range counter stays inside the function, gets formatted immediately, and never leaks out. That's the pattern.
Tomorrow is break and continue? I'm guessing that's where I can make range-based loops skip or stop early — like stopping a scanner when you hit a reserved aisle.
Exactly. break cuts a loop the moment a condition is met. continue skips a single iteration. And Python's loop else clause runs when the loop finishes without a break — which surprises almost everyone who sees it.
A loop else. I'm already suspicious of that.
Come back tomorrow and find out. Write generate_skus first.
range() creates a range object — a lazy integer sequence that calculates values on demand without storing them all in memory. Three forms:
range(stop) # 0 to stop-1
range(start, stop) # start to stop-1
range(start, stop, step) # start to stop-1, stepping by step
# Examples
list(range(5)) # [0, 1, 2, 3, 4]
list(range(1, 6)) # [1, 2, 3, 4, 5]
list(range(0, 10, 2)) # [0, 2, 4, 6, 8]
list(range(5, 0, -1)) # [5, 4, 3, 2, 1]Memory efficiency: A range(1, 1_000_000) uses the same ~48 bytes as range(1, 10). The numbers only exist when something iterates through them. This matters when generating large sequences of identifiers.
SKU generation pattern:
def generate_skus(prefix: str, start: int, count: int) -> list[str]:
return [f"{prefix}-{i:03d}" for i in range(start, start + count)]
generate_skus("WH", 1, 5) # ["WH-001", "WH-002", ..., "WH-005"]
generate_skus("WH", 48, 3) # ["WH-048", "WH-049", "WH-050"]F-string format spec {i:03d}: 0 = pad character, 3 = minimum width, d = decimal integer. Produces zero-padded three-digit numbers. Equivalent to str(i).zfill(3) but inline.
Off-by-one defense: Compute the stop from what you actually want — range(start, start + count) always gives exactly count iterations regardless of start. Hardcoding or guessing the endpoint is the source of most range bugs.
Pitfall 1: range(start, count) instead of range(start, start + count). If start=50 and count=10, range(50, 10) is empty because stop < start. Always compute: range(start, start + count).
Pitfall 2: Expecting range to include the stop value. range(1, 50) gives 1 through 49 — 49 values, not 50. The stop is always exclusive. For N items starting at start, use range(start, start + N).
Pitfall 3: Using floats with range. range(0, 1, 0.1) raises TypeError: 'float' object cannot be interpreted as an integer. Use a list comprehension for float sequences: [i / 10 for i in range(10)].
range objects support len(), indexing (range(1, 100)[5] → 6), and membership testing (47 in range(1, 100) → True) — all in O(1) time without iterating. They are also reversible: reversed(range(1, 51)) iterates 50 down to 1 without building a list. Python's enumerate() pairs range-like counting with actual sequence items: for i, product in enumerate(products, start=1) gives 1-based indices alongside each element — often cleaner than range(len(products)).
Sign up to write and run code in this lesson.
The warehouse has aisles numbered one through fifty. If I asked you to build a loop that processed every aisle, what would you write first?
I'd feel a strong urge to write [1, 2, 3, ...] out by hand and immediately know I was doing something wrong.
You'd be right to feel that. Python has range() for exactly this. You've used it before — for i in range(10) — but I want to peel it apart today because most people treat it as loop syntax and miss what it actually is.
I always thought of range() as part of the for statement. Like, the two come as a pair.
Range is a standalone object. It knows a start, a stop, and a step — and it calculates each number on demand without storing them all at once. A range(1, 1_000_000) uses roughly the same memory as range(1, 10). Three forms:
print(list(range(5))) # [0, 1, 2, 3, 4]
print(list(range(1, 6))) # [1, 2, 3, 4, 5]
print(list(range(0, 10, 2))) # [0, 2, 4, 6, 8]The list() call — you're converting it to see it? What is it before that?
A range object. Not a list. Think of it as the warehouse knowing "aisles start at 1, go up to 50, step by 1" — you don't need fifty index cards in a drawer. You just need the rule. The loop or the conversion applies the rule when it needs actual numbers.
That's much more efficient for large sequences. If I'm generating a batch of SKUs — WH-001 through WH-500 — I don't need all five hundred strings sitting in memory at once.
Exactly. And SKU generation is the real use case today. Let's say Diane needs a fresh batch of warehouse codes: prefix "WH", starting at 1, count of 5. What would range() look like for that?
I want exactly count numbers starting at start. That's range(start, start + count) — I compute the stop from what I actually want rather than guessing the endpoint.
That habit will save you from off-by-one bugs. range(start, count) by mistake gives you the wrong numbers if start is nonzero. range(start, start + count) always gives you exactly count iterations regardless of where you start. Let's also talk formatting. The raw number from range needs zero-padding:
# Without padding:
for i in range(1, 4):
print(f"WH-{i}")
# WH-1, WH-2, WH-3
# With format spec:
for i in range(1, 4):
print(f"WH-{i:03d}")
# WH-001, WH-002, WH-003{i:03d} — zero-pad to three digits. I know str(i).zfill(3) does the same but keeping it in the f-string is cleaner.
Both work. Now write the whole function before I show you anything:
generate_skus(prefix, start, count) — range from start to start + count, format each i with {prefix}-{i:03d}, collect into a list.
def generate_skus(prefix: str, start: int, count: int) -> list[str]:
skus = []
for i in range(start, start + count):
skus.append(f"{prefix}-{i:03d}")
return skusRight on the first attempt. And you computed the stop yourself — that's the key move. Let me show you the idiomatic one-liner:
def generate_skus(prefix: str, start: int, count: int) -> list[str]:
return [f"{prefix}-{i:03d}" for i in range(start, start + count)]A list comprehension with range inside. Range doesn't care whether it's in a for loop or a comprehension — it just hands out numbers when asked. Same logic, shorter form.
Both versions are correct. The explicit loop is easier to debug — you can add a print inside it. The comprehension is cleaner in production code once you're confident in the logic.
One thing I want to clarify — is range inclusive on both ends? range(1, 50) — does that include 50?
Start-inclusive, stop-exclusive. Always. range(1, 50) gives you 1 through 49. Fifty aisles means range(1, 51). This matches Python slices: my_list[1:5] gives indices 1 through 4. Same rule, consistent across the language.
So "aisles 1 to 50" in English includes aisle 50, but range(1, 50) doesn't. That's the off-by-one error you mentioned.
It has claimed every Python developer I know at least once. range(start, start + count) is the defensive form — you never have to think about the endpoint, just what you actually want.
What about floats? If I needed every half-aisle mark — 1.0, 1.5, 2.0?
Range is integers only. range(0, 5, 0.5) raises a TypeError. For float sequences you'd use a list comprehension with division, or numpy.linspace — but that's a different tool for a different day. For SKU indices, batch IDs, loop counters — integers are always what you want.
Integers are the warehouse use case anyway. SKU numbers, aisle counts, order batch IDs — all whole numbers.
Exactly. And notice what you've built: a clean, testable function. You give it a prefix, a start, and a count — it returns a predictable list. The range counter stays inside the function, gets formatted immediately, and never leaks out. That's the pattern.
Tomorrow is break and continue? I'm guessing that's where I can make range-based loops skip or stop early — like stopping a scanner when you hit a reserved aisle.
Exactly. break cuts a loop the moment a condition is met. continue skips a single iteration. And Python's loop else clause runs when the loop finishes without a break — which surprises almost everyone who sees it.
A loop else. I'm already suspicious of that.
Come back tomorrow and find out. Write generate_skus first.
range() creates a range object — a lazy integer sequence that calculates values on demand without storing them all in memory. Three forms:
range(stop) # 0 to stop-1
range(start, stop) # start to stop-1
range(start, stop, step) # start to stop-1, stepping by step
# Examples
list(range(5)) # [0, 1, 2, 3, 4]
list(range(1, 6)) # [1, 2, 3, 4, 5]
list(range(0, 10, 2)) # [0, 2, 4, 6, 8]
list(range(5, 0, -1)) # [5, 4, 3, 2, 1]Memory efficiency: A range(1, 1_000_000) uses the same ~48 bytes as range(1, 10). The numbers only exist when something iterates through them. This matters when generating large sequences of identifiers.
SKU generation pattern:
def generate_skus(prefix: str, start: int, count: int) -> list[str]:
return [f"{prefix}-{i:03d}" for i in range(start, start + count)]
generate_skus("WH", 1, 5) # ["WH-001", "WH-002", ..., "WH-005"]
generate_skus("WH", 48, 3) # ["WH-048", "WH-049", "WH-050"]F-string format spec {i:03d}: 0 = pad character, 3 = minimum width, d = decimal integer. Produces zero-padded three-digit numbers. Equivalent to str(i).zfill(3) but inline.
Off-by-one defense: Compute the stop from what you actually want — range(start, start + count) always gives exactly count iterations regardless of start. Hardcoding or guessing the endpoint is the source of most range bugs.
Pitfall 1: range(start, count) instead of range(start, start + count). If start=50 and count=10, range(50, 10) is empty because stop < start. Always compute: range(start, start + count).
Pitfall 2: Expecting range to include the stop value. range(1, 50) gives 1 through 49 — 49 values, not 50. The stop is always exclusive. For N items starting at start, use range(start, start + N).
Pitfall 3: Using floats with range. range(0, 1, 0.1) raises TypeError: 'float' object cannot be interpreted as an integer. Use a list comprehension for float sequences: [i / 10 for i in range(10)].
range objects support len(), indexing (range(1, 100)[5] → 6), and membership testing (47 in range(1, 100) → True) — all in O(1) time without iterating. They are also reversible: reversed(range(1, 51)) iterates 50 down to 1 without building a list. Python's enumerate() pairs range-like counting with actual sequence items: for i, product in enumerate(products, start=1) gives 1-based indices alongside each element — often cleaner than range(len(products)).