AlgoMaster Logo

len & range

High Priority19 min readUpdated June 6, 2026
Listen to this chapter
Unlock Audio

len answers "how many?" and range produces "an arithmetic sequence". They're two of the first built-ins anyone learns in Python, and they look almost too simple to spend a chapter on. The interesting parts sit underneath: len calls a special method that any class can implement, and range isn't a list at all but a virtual sequence that supports indexing, slicing, and O(1) membership tests on huge ranges. This lesson covers both, including the rough edges.

len: One Function, One Special Method

len(obj) returns the number of items in a container. It works on sequences (lists, tuples, strings, bytes, ranges), mappings (dicts, sets, frozensets), and any user-defined class that implements __len__.

For a dict, len(cart) counts the keys (which is the same as counting entries). For a string, it counts characters, not bytes, because Python 3 strings are Unicode code points. For bytes, the result is the actual byte count.

That difference matters for sizing buffers, validating input lengths, or checking the wire size of a payload. len(string) is about user-visible characters; len(bytes) is about storage. Mixing them up is a common encoding bug.

When Python sees len(obj), it calls type(obj).__len__(obj), which is the type's __len__ method. That's how a custom class can plug in:

Now the cart works with len() like any built-in container. The Python convention is that __len__ should return a non-negative integer and be O(1) where possible. Callers assume len is cheap; an implementation that walks the whole structure to compute a length breaks that assumption.

len() is O(1) for all built-in containers because they cache the count internally. Lists, dicts, sets, and tuples all store the size as a field on the object, so len is a direct read. A custom class that loops through its contents to count would break that contract and slow down any code that calls len in a loop.

What Doesn't Have a len

One surprise: generators and other iterators don't have a length. There's no way to know how many items a generator will produce without running it.

The same goes for map, filter, zip, enumerate, file objects, and any other iterator. When a count is required, materialize first with list(...) and then call len, but that defeats the lazy-iteration benefit.

For counting matches without keeping the items, the pattern is sum(1 for x in iterator if condition). That walks the iterator once and produces a count without building a list.

Each yielded order contributes a 1 to the sum when the condition holds, and 0 otherwise. The result is a count without an intermediate list.

A subtler detail: len(dict) counts the keys, not the total number of key-value pairs as entries with values. The two happen to be the same number, and len(some_dict.values()) returns the same value as len(some_dict).

A dict has 3 entries, so keys, values, and items are all views of size 3.

range: Three Calling Forms

range produces an arithmetic sequence of integers. It has three calling forms, and the rules around the arguments matter.

The three forms are:

CallBehavior
range(stop)0, 1, ..., stop - 1
range(start, stop)start, start + 1, ..., stop - 1
range(start, stop, step)start, start + step, ..., < stop (or > stop for negative step)

The important rule: the stop value is exclusive. range(5) produces five values, but the largest is 4, not 5. The reason traces back to half-open intervals being mathematically cleaner (splitting range(0, 10) into range(0, 5) and range(5, 10) works without overlap), but the practical takeaway is: count the values, don't read off the last one.

A negative step walks backwards, but start must be greater than stop:

The first and third cases produce no values because the step doesn't move toward stop. range returns the empty sequence in those cases, which is occasionally useful and occasionally a bug. There's no error.

range requires integers. Floats raise TypeError.

For arithmetic sequences of floats, use numpy.arange (if the project already uses the scientific stack) or a generator that multiplies through. The standard-library range is integer-only on purpose.

Creating a range is O(1) regardless of how large the range is. range(10**18) is fine. The object stores only the start, stop, and step. Values are computed on demand during iteration, indexing, or slicing. Compare with list(range(10**18)), which would try to allocate a quintillion-element list and crash long before finishing.

range Is a Virtual Sequence

A range object isn't a list, but it isn't a generator either. It's a third thing: a sequence with no underlying storage. Python calls it a "virtual sequence" because it computes values on demand from a small three-integer recipe.

A range supports almost everything a list supports: indexing, slicing, length, membership tests, even reversal.

The slice r[10:15] returns a new range object, not a list. The new range has its own start, stop, and step computed from the original. No values are stored in either object.

The membership test 50 in r is also special. Python knows the arithmetic structure, so it doesn't iterate to check; it solves for "is 50 an element of range(0, 100, 5)?" in O(1). 50 - 0 = 50, 50 % 5 == 0, and 50 < 100, so the answer is yes. The same check on a list (50 in [0, 5, 10, ..., 95]) walks the list in O(n).

A range of a billion elements, and the membership check is instant. Both len and in work in O(1) because they're driven by integer arithmetic, not iteration.

The diagram shows what range supports and the cost of each operation. Every shape-related operation (length, indexing, slicing, membership) is O(1) because Python computes it directly from start, stop, and step. Only iteration walks the values, and even then it generates them one at a time without storing the whole sequence.

x in some_list is O(n). x in some_range is O(1). For repeated membership checks against an arithmetic sequence, keep it as a range instead of materializing it to a list.

range Doesn't Build a List (And Used to, in Python 2)

A quick historical note. In Python 2 there were two functions: range returned a list, and xrange returned the lazy object. The lazy version was better for almost every use case, so Python 3 dropped the list-returning version, renamed xrange to range, and removed xrange entirely.

This detail only matters when reading old code or a tutorial written before 2010. A snippet that calls xrange is Python 2. The Python 3 equivalent is range.

The practical consequence today: don't call list(range(...)) unless an actual list is needed. For looping (for i in range(...)), the bare range is faster and uses constant memory. The list call adds an O(n) allocation for no benefit.

list(range(...)) is only needed when an actual list is required (to mutate it, pass it somewhere that expects a list, or index into it after operations that aren't supported on range objects, which is rare).

Common Patterns with range

These patterns show up often enough that they're worth seeing together.

Pattern 1: Iterate with an index when the value is also needed

The first instinct is to write for i in range(len(items)) and index into the list. That works, but enumerate is cleaner.

Use enumerate instead of range(len(...)). The few places range(len(...)) is better are when the list needs to be modified during iteration (rare and best avoided) or when integer indices are needed for some other arithmetic.

Pattern 2: Generate an arithmetic sequence

The "every Nth" pattern is the common use of range's step argument. For dates, prices in $5 increments, batch indices, or pagination offsets, the step keeps the loop concise.

Pattern 3: Reverse a loop without making a copy

The triple (len(products) - 1, -1, -1) reads as "start at the last index, stop before -1 (which means stop at 0 inclusive), step backwards by 1". It's a bit fiddly, but it gives both the reversed order and the original indices. reversed(range(len(products))) does the same thing more readably and is the more common form.

Pattern 4: Build a list of indices or a number sequence

When an actual list of integers is needed (to mutate, to shuffle, to pass to a function), list(range(...)) is the idiomatic way to get one.

Pattern 5: Repeat an operation N times

The underscore is a convention for "the loop variable is unused". This is the "do this N times" idiom: a range with no body that uses the variable, just iterating for the side effect.

Equality and Comparison of range Objects

Two range objects are equal if they represent the same sequence of values, not if they have the same start, stop, and step. That distinction matters for empty ranges and for ranges that happen to produce identical output.

range(0) and range(2, 2) are both empty, so they compare equal even though they have different parameters. range(0, 3, 2) and range(0, 4, 2) both produce [0, 2], so they also compare equal. But range(5) == list(range(5)) is False because a range and a list are different types, even when their contents match.

This is consistent with Python's general "same type for ==" rule across most built-in containers (tuple([1, 2]) == [1, 2] is False, but [1, 2] == [1, 2] is True). range's equality follows that rule; it just looks at the represented sequence rather than the parameters.

range1 == range2 is O(1). Python compares the lengths first, then the first elements, then the steps, all in constant time. It does not iterate the values to check.

len and range Together

len and range are often used in the same expression: range(len(items)) is the classic shape for "produce all valid indices into a sequence". enumerate is almost always cleaner, but the explicit form is still useful in some places.

For walking two lists in lockstep, zip is the right tool. The range(len(...)) form is mostly for cases where the index is needed for some additional reason (modifying the list, computing a windowed slice, doing arithmetic on the index).

A reverse-index pattern that fits range well:

Walking forward and deleting elements would shift the indices and cause skipped items. Walking backward keeps the upcoming indices stable. The range(len(items) - 1, -1, -1) is the classic way to express "all valid indices, in reverse".

The more Pythonic version doesn't mutate in place at all: items = [x for x in items if x % 2 != 0] builds a new list with the kept items and is easier to read. Use the reverse-index pattern only when the original list must be mutated.

Quiz

len & range Quiz

10 quizzes