This solved function converts a human page number into a Python index. Read the last comment carefully — it is the reason your function needs a guard.
def nth_row(rows, number):
"""number is 1-based, the way people count."""
return rows[number - 1]
# nth_row(['a', 'b', 'c'], 1) -> 'a'
# nth_row(['a', 'b', 'c'], 3) -> 'c'
# nth_row(['a', 'b', 'c'], 0) -> 'c' <- no error, and completely wrong
- The subtraction happens once, at the boundary
Convert from human numbering to Python numbering at the point the value enters your function, then work in Python’s terms from there. Code that sprinkles - 1 through several lines eventually applies it twice or not at all.
- Negative indices are why 0 is dangerous
rows[-1] is the last row, so passing page 0 does not fail — it silently returns the wrong end of the data. An error you can see beats a wrong answer you cannot, which is the whole argument for validating input.
- Indexing and slicing behave differently past the end
rows[99] on a three-item list raises IndexError, but rows[99:102] returns []. Your function slices, so the page-past-the-end case needs no code of its own — provided you do not add an if for it.
Work out the start index by hand for a few pages with per_page = 3: page 1 starts at 0, page 2 at 3, page 3 at 6. What arithmetic turns the page number into that start?
start = (page - 1) * per_page, and the page ends per_page items later. A slice from start to start + per_page gives you the page, and handles both the short last page and the page past the end on its own.
Validate first — page < 1 and per_page < 1 both raise ValueError, and both checks must come before any arithmetic. Then compute start and return the slice. There is no loop and no if for the empty cases.
def paginate(items, page, per_page): if page < ____ or per_page < ____: raise ValueError(...) start = (____ - 1) * ____ return items[start:____]
def paginate(items, page, per_page):
if page < 1:
raise ValueError(f"page starts at 1, got {page}")
if per_page < 1:
raise ValueError(f"per_page must be at least 1, got {per_page}")
start = (page - 1) * per_page
return items[start:start + per_page]
It is shorter to write `if page < 1 or per_page < 1` and raise once, and it is worse: the caller then has to work out which argument offended. An error message that names the parameter and shows the value turns a debugging session into a glance. Cheap to write, repaid every time it fires.
Page 5 of a 10-item list in threes is empty, and that is the normal way a client discovers it has reached the end — it keeps asking until a page comes back empty. Raising here would turn ordinary iteration into exception handling. A page number below 1, by contrast, cannot be produced by correct code at all, which is exactly why one raises and the other does not.
Full pages, the short final page and the page past the end are all the same expression, because slicing clamps to what exists. Every if you were tempted to add for those cases is a line that could be wrong.
Do not paginate a list in memory when the data lives in a database — LIMIT and OFFSET exist so the rows you skip are never fetched, and loading 10,000 rows to return 20 defeats the purpose. Be aware too that offset pagination shifts when rows are inserted while a user is reading: item 20 can appear on page 1 and page 2. Where that matters, cursor pagination — 'give me what comes after this id' — is the sturdier design.
- Return a small summary alongside the page — total items, total pages, whether a next page exists — and decide what shape makes that pleasant to use.
- Add clamping behind an option: page_beyond_end='last' returns the final page instead of an empty one. Argue with yourself about whether that is helpful or dishonest.
- Implement cursor pagination for the same list: paginate_after(items, after_id, count), and list what it fixes and what it gives up.