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

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: False

Output: 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: True

Output: 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"])                   # False

Common 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.

TaskCode
Offset(page - 1) * per_page
Total pagesmath.ceil(total / per_page)
DB slice.limit(per_page).offset(offset)
Has nextpage < total_pages

🏆 Mini Challenge: Add Page Links

Extend a paginated response with prev/next page numbers.

# 🏆 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

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.