Pagination
Reviewed & published by Brayan K
Pagination is the technique of returning a large result set in small, numbered slices — a page at a time — instead of all rows at once, keeping responses fast and manageable.
Part of the free Flask course at LearnCodingFast — hands-on lessons with worked examples and the output they print, plus practice exercises and a quick quiz.
In this lesson you'll work the offset/limit math, compute total pages and prev/next flags, build a JSON paginated API response, and apply the same logic with SQLAlchemy.
What You'll Learn in This Lesson
- The page and per_page parameters
- Computing offset = (page - 1) * per_page
- Calculating total pages with ceil
- Setting has_prev and has_next flags
- Returning a JSON paginated response
- Applying limit/offset in SQLAlchemy
1 The Pagination Math
Pagination boils down to two numbers the client sends: page (which slice) and per_page (how big). From those you compute everything else. The offset — how many items to skip — is (page - 1) * per_page. The total pages is the item count divided by per_page, rounded up.
The runnable example builds a paginate() helper over a plain list of 23 items and asks for page 2 of 10-per-page.
# pagination.py — the core math over a plain list (runnable)
import math
ITEMS = [f"item-{i}" for i in range(1, 24)] # 23 items total
def paginate(items, page, per_page):
total = len(items)
total_pages = math.ceil(total / per_page) # round up for the last partial page
offset = (page - 1) * per_page # how many to skip
window = items[offset:offset + per_page] # the slice for this page
return {
"page": page,
"per_page": per_page,
"total": total,
"total_pages": total_pages,
"has_prev": page > 1,
"has_next": page < total_pages,
"items": window,
}
result = paginate(ITEMS, page=2, per_page=10)
print("page", result["page"], "of", result["total_pages"])
print("items:", result["items"])
print("has_prev:", result["has_prev"], "| has_next:", result["has_next"])
last = paginate(ITEMS, page=3, per_page=10)
print("last page items:", last["items"], "| has_next:", last["has_next"])
# ✅ Expected output:
# page 2 of 3
# items: ['item-11', 'item-12', 'item-13', 'item-14', 'item-15', 'item-16', 'item-17', 'item-18', 'item-19', 'item-20']
# has_prev: True | has_next: True
# last page items: ['item-21', 'item-22', 'item-23'] | has_next: FalseOutput: page 2 of 3 shows item-11…item-20 with both flags true; page 3 shows the final three items and has_next: False.
2 A Paginated JSON API Endpoint
In a real API the client passes ?page=2&per_page=10 as query parameters, and you return JSON with both the items and the navigation metadata. The example wraps the math in a Flask route, reads the params with sensible defaults, and returns a structured response — tested with the in-process client.
# app.py — a paginated JSON endpoint (runnable via test_client)
import math
from flask import Flask, request, jsonify, url_for
app = Flask(__name__)
PRODUCTS = [{"id": i, "name": f"Product {i}"} for i in range(1, 24)] # 23 products
@app.route("/api/products")
def list_products():
page = request.args.get("page", 1, type=int)
per_page = request.args.get("per_page", 10, type=int)
total = len(PRODUCTS)
total_pages = math.ceil(total / per_page)
offset = (page - 1) * per_page
items = PRODUCTS[offset:offset + per_page]
return jsonify({
"items": items,
"page": page,
"per_page": per_page,
"total": total,
"total_pages": total_pages,
"has_prev": page > 1,
"has_next": page < total_pages,
})
with app.test_client() as client:
data = client.get("/api/products?page=2&per_page=10").get_json()
print("page:", data["page"], "of", data["total_pages"])
print("names:", [p["name"] for p in data["items"]])
print("has_next:", data["has_next"])
# ✅ Expected output:
# page: 2 of 3
# names: ['Product 11', 'Product 12', 'Product 13', 'Product 14', 'Product 15', 'Product 16', 'Product 17', 'Product 18', 'Product 19', 'Product 20']
# has_next: TrueOutput: page: 2 of 3, the ten product names for that page, and has_next: True. The same shape works for any list-style API.
3 Pagination at the Database Level
Slicing a Python list is fine for small data, but with thousands of rows you want the database to return only the page you need. That is exactly what .limit(per_page).offset(offset) does — plus one cheap count() to know the total.
# app.py — pagination with SQLAlchemy limit/offset (runnable)
import math
from sqlalchemy import create_engine, String, select, func
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, Session
class Base(DeclarativeBase): pass
class Product(Base):
__tablename__ = "products"
id: Mapped[int] = mapped_column(primary_key=True)
name: Mapped[str] = mapped_column(String(50))
engine = create_engine("sqlite://")
Base.metadata.create_all(engine)
with Session(engine) as s:
s.add_all([Product(name=f"P{i}") for i in range(1, 24)]) # 23 rows
s.commit()
page, per_page = 2, 10
total = s.scalar(select(func.count()).select_from(Product)) # one count query
total_pages = math.ceil(total / per_page)
rows = s.scalars(
select(Product).order_by(Product.id)
.limit(per_page).offset((page - 1) * per_page)
).all()
print("total:", total, "pages:", total_pages)
print("page 2 names:", [p.name for p in rows])
# ✅ Expected output:
# total: 23 pages: 3
# page 2 names: ['P11', 'P12', 'P13', 'P14', 'P15', 'P16', 'P17', 'P18', 'P19', 'P20']Output: total: 23 pages: 3 and page 2 names: ['P11', …, 'P20'] — only ten rows ever leave the database.
Flask-SQLAlchemy bundles all of this into one call. Here is the production shortcut:
🎯 Your Turn: Fill in the Blanks
Complete the paginator. Replace each ___ to compute the offset, total pages, and next flag.
# 🎯 YOUR TURN: replace each ___ to finish the paginator
import math
ITEMS = list(range(1, 46)) # 45 items
def paginate(items, page, per_page):
total = len(items)
total_pages = math.___(total / per_page) # round up
offset = (page - ___) * per_page # skip earlier pages
window = items[offset:offset + per_page]
return {
"page": page,
"total_pages": total_pages,
"items": window,
"has_next": page ___ total_pages, # is there a page after this?
}
r = paginate(ITEMS, page=3, per_page=20)
print("page 3 of", r["total_pages"]) # page 3 of 3
print("items:", r["items"]) # the last 5 items
print("has_next:", r["has_next"]) # FalseCommon Errors & Quick Reference
Pages are 1-based but offsets are 0-based. Use (page - 1) * per_page, not page * per_page, or page 1 will skip the first batch.
❌ TypeError: page came in as a string
Query params are strings. Read them with request.args.get("page", 1, type=int) so the math doesn't break.
❌ Integer division dropped the last page
Use math.ceil(total / per_page). Plain // truncates, hiding a final partial page of results.
| Task | Code |
|---|---|
| Offset | (page - 1) * per_page |
| Total pages | math.ceil(total / per_page) |
| DB slice | .limit(per_page).offset(offset) |
| Has next | page < total_pages |
🏆 Mini Challenge: Add Page Links
Extend a paginated response with prev/next page numbers.
- Compute the slice for the requested page
- Add next_page (or null) and prev_page (or null)
- Return the metadata as JSON
# 🏆 Mini Challenge: prev/next page numbers
import math
from flask import Flask, request, jsonify
app = Flask(__name__)
DATA = list(range(1, 31)) # 30 items
@app.route("/api/items")
def items():
page = request.args.get("page", 1, type=int)
per_page = request.args.get("per_page", 10, type=int)
total = len(DATA)
total_pages = math.ceil(total / per_page)
offset = (page - 1) * per_page
return jsonify({
"page": page,
"total_pages": total_pages,
"items": DATA[offset:offset + per_page],
"next_page": page + 1 if page < total_pages else None,
"prev_page": page - 1 if page > 1 else None,
})
with app.test_client() as c:
d = c.get("/api/items?page=2").get_json()
print("page:", d["page"], "of", d["total_pages"])
print("items:", d["items"])
print("prev:", d["prev_page"], "next:", d["next_page"])❓ Frequently Asked Questions
Lesson complete — your APIs stay fast at any size!
You can compute offsets and total pages, set has_prev/has_next, return clean paginated JSON, and push the slicing down into the database with limit and offset.
Practice quiz
How do you compute the offset for a given page?
- (page - 1) * per_page
- page * per_page
- page + per_page
- per_page / page
Answer: (page - 1) * per_page. Pages are 1-based, so page 1 must skip 0 items: (page - 1) * per_page.
What does per_page control?
- The total row count
- How many items each page returns
- The current page number
- The sort order
Answer: How many items each page returns. per_page is the limit — how many items appear on a single page.
How do you compute total pages so a final partial page still counts?
- total // per_page
- round(total / per_page)
- math.ceil(total / per_page)
- math.floor(total / per_page)
Answer: math.ceil(total / per_page). math.ceil rounds up so a leftover partial page is counted as a full page.
When is has_next true?
- Always
- When page equals 1
- When per_page is large
- When page < total_pages
Answer: When page < total_pages. There is a next page only while the current page is below the total page count.
At the database level, which methods return just one page of rows?
- .limit(per_page).offset(offset)
- .all().slice()
- .filter(page)
- .group_by(page)
Answer: .limit(per_page).offset(offset). .limit() plus .offset() makes the database return only the needed window.
How should you read the page query parameter as an integer?
- int(request.page)
- request.args.get("page", 1, type=int)
- request.form["page"]
- request.json["page"]
Answer: request.args.get("page", 1, type=int). Query params are strings; passing type=int converts page to an integer with a default.
What does Flask-SQLAlchemy's query.paginate() return?
- A plain list of rows
- A SQL string
- A Pagination object with .items, .pages, .total
- A tuple (page, per_page)
Answer: A Pagination object with .items, .pages, .total. paginate() returns a Pagination object exposing .items, .page, .pages, .total, has_next/has_prev.
Why is has_prev false on page 1?
- per_page is zero
- total_pages is unknown
- The list is empty
- Because page > 1 is False
Answer: Because page > 1 is False. has_prev is page > 1, which is False on the first page.
What bug does using page * per_page (instead of (page-1)*per_page) cause?
- Page 1 skips the first batch of items
- Pages load twice
- The total count is wrong
- It raises a SyntaxError
Answer: Page 1 skips the first batch of items. Without the -1, page 1 offsets by per_page and skips the first set of rows.
Why paginate instead of returning every row at once?
- It changes the sort order
- Responses stay small and fast
- It encrypts the data
- It removes duplicates
Answer: Responses stay small and fast. Sending a fixed-size slice keeps responses fast and avoids overwhelming the client.
Continue this course
- Previous: Querying with SQLAlchemy
- Next: CORS & Cross-Origin Requests — Allow browsers from other origins to call your API safely
- Quick reference: Flask cheat sheet
Frequently asked questions
Why paginate instead of returning everything?
Returning thousands of rows in one response is slow, wastes bandwidth, and can crash the browser or your server. Pagination sends a small, fixed-size slice — say 10 or 20 items — plus metadata so the client can request the next slice on demand.
How do offset and limit relate to page and per_page?
limit is just per_page — how many items to return. offset is how many to skip, computed as (page - 1) * per_page. So page 1 skips 0, page 2 skips per_page, and so on. The database returns exactly that window.
How do I compute the total number of pages?
Divide the total item count by per_page and round up: total_pages = ceil(total / per_page). Using math.ceil (or (total + per_page - 1) // per_page) ensures a final partial page still counts as one full page.
What does Flask-SQLAlchemy's paginate() return?
query.paginate(page=p, per_page=n) returns a Pagination object with .items (the current slice), .page, .pages (total pages), .total, plus .has_next and .has_prev booleans and .next_num / .prev_num. It runs the count and the limited query for you.