Skip to content
intermediatePhase 27 · SQL Performance

Pagination Patterns

Master offset pagination and keyset pagination for large datasets.

45m
3 problems
Topic Progress0%

OFFSET/LIMIT Pagination

OFFSET/LIMIT Pagination

The most common pagination approach uses OFFSET to skip rows and LIMIT to return a fixed number of rows per page.

Basic Syntax

-- Page 1: First 10 rows
SELECT * FROM orders 
ORDER BY order_date DESC 
LIMIT 10 OFFSET 0;

-- Page 2: Next 10 rows
SELECT * FROM orders 
ORDER BY order_date DESC 
LIMIT 10 OFFSET 10;

-- Page 3: Next 10 rows
SELECT * FROM orders 
ORDER BY order_date DESC 
LIMIT 10 OFFSET 20;

-- General formula for page P with size S:
-- OFFSET = (P - 1) × S
-- LIMIT = S

Application Code Example

def get_orders_page(page_number, page_size=10):
    offset = (page_number - 1) * page_size
    query = f"""
        SELECT id, customer_id, total, order_date
        FROM orders 
        ORDER BY order_date DESC 
        LIMIT {page_size} OFFSET {offset}
    """
    return db.execute(query)

# Page 1
orders = get_orders_page(1)  # OFFSET 0, LIMIT 10

# Page 2
orders = get_orders_page(2)  # OFFSET 10, LIMIT 10

Total Count for UI

-- Get total count for pagination UI
SELECT COUNT(*) FROM orders;

-- Or with filters
SELECT COUNT(*) FROM orders WHERE status = 'pending';

-- Combined query
SELECT 
    o.*, 
    (SELECT COUNT(*) FROM orders WHERE status = 'pending') as total_count
FROM orders o
WHERE o.status = 'pending'
ORDER BY o.order_date DESC
LIMIT 10 OFFSET 20;

Limitations

  • Slow for large offsets: OFFSET 1000000 must scan and skip 1M rows
  • Inconsistent results: Rows may appear/disappear if data changes between pages
  • Database must process all skipped rows even though they're not returned

Problems with Large Offsets

Why Large Offsets Are Slow

OFFSET-based pagination degrades significantly as the offset increases. The database must scan and discard all skipped rows before returning results.

How OFFSET Works Internally

-- OFFSET 10: Database scans 10 rows, discards them, returns next 10
SELECT * FROM orders ORDER BY id LIMIT 10 OFFSET 10;
-- Scans 20 rows, returns 10

-- OFFSET 1000000: Database scans 1M rows, discards them!
SELECT * FROM orders ORDER BY id LIMIT 10 OFFSET 1000000;
-- Scans 1,000,010 rows, returns 10

Performance Comparison

OFFSET 0:        2ms    (scan 10 rows)
OFFSET 100:      5ms    (scan 110 rows)
OFFSET 1,000:    25ms   (scan 1,010 rows)
OFFSET 10,000:   200ms  (scan 10,010 rows)
OFFSET 100,000:  2.0s   (scan 100,010 rows)
OFFSET 1,000,000: 20s   (scan 1,000,010 rows)

Linear degradation!

Visual Explanation

Table: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]

OFFSET 0, LIMIT 3:  Return [1, 2, 3]
OFFSET 3, LIMIT 3:  Return [4, 5, 6]
OFFSET 6, LIMIT 3:  Return [7, 8, 9]

To get [7, 8, 9], database must:
1. Read rows 1-6 (skip them)
2. Return rows 7-9

Mitigation Strategies

-- 1. Cap maximum page size
SELECT * FROM orders 
ORDER BY order_date DESC 
LIMIT LEAST(100, :requested_limit) OFFSET :offset;

-- 2. Warn users about deep pages
-- "Showing results 1-10 of 1,000,000. Use search for more specific results."

-- 3. Use keyset pagination instead (see next chapter)

-- 4. Cache total counts
-- Don't run COUNT(*) on every page request

Real-World Example

-- E-commerce product listing
-- Page 1: Fast
SELECT * FROM products ORDER BY id LIMIT 20 OFFSET 0;

-- Page 50,000: Very slow
SELECT * FROM products ORDER BY id LIMIT 20 OFFSET 999980;
-- Must scan 1,000,000 rows to skip them!

-- Solution: Use keyset pagination
SELECT * FROM products WHERE id > 999980 ORDER BY id LIMIT 20;
-- Uses index, scans only 20 rows!

Keyset (Cursor-Based) Pagination

Keyset Pagination

Keyset pagination (also called cursor-based pagination) uses the last seen value to fetch the next page. Instead of skipping rows with OFFSET, it uses a WHERE clause to start from a specific point.

Basic Concept

-- Instead of: OFFSET 100, LIMIT 10
-- Use: WHERE id > last_seen_id

-- Page 1
SELECT * FROM orders 
ORDER BY id 
LIMIT 10;
-- Returns rows with ids: 1, 2, 3, ..., 10
-- Last seen id: 10

-- Page 2 (using last seen id)
SELECT * FROM orders 
WHERE id > 10 
ORDER BY id 
LIMIT 10;
-- Returns rows with ids: 11, 12, 13, ..., 20
-- Last seen id: 20

-- Page 3
SELECT * FROM orders 
WHERE id > 20 
ORDER BY id 
LIMIT 10;
-- Returns rows with ids: 21, 22, 23, ..., 30

Application Code

def get_orders_cursor(cursor=None, page_size=10):
    if cursor is None:
        # First page
        query = """
            SELECT id, customer_id, total, order_date
            FROM orders 
            ORDER BY id 
            LIMIT %s
        """
        params = [page_size]
    else:
        # Subsequent pages
        query = """
            SELECT id, customer_id, total, order_date
            FROM orders 
            WHERE id > %s
            ORDER BY id 
            LIMIT %s
        """
        params = [cursor, page_size]
    
    rows = db.execute(query, params)
    
    # Next cursor is the last row's id
    next_cursor = rows[-1]['id'] if rows else None
    
    return rows, next_cursor

# Usage
page1, cursor1 = get_orders_cursor()
page2, cursor2 = get_orders_cursor(cursor=cursor1)
page3, cursor3 = get_orders_cursor(cursor=cursor2)

Keyset with Composite Sort

-- Sort by order_date DESC, then id (for tiebreaking)
SELECT * FROM orders 
WHERE (order_date, id) < ('2024-01-15', 12345)
ORDER BY order_date DESC, id DESC 
LIMIT 10;

-- Or using row value constructor (PostgreSQL, MySQL)
SELECT * FROM orders 
WHERE order_date < '2024-01-15' 
   OR (order_date = '2024-01-15' AND id < 12345)
ORDER BY order_date DESC, id DESC 
LIMIT 10;

Cursor Encoding (API Design)

import base64
import json

def encode_cursor(order_id, order_date):
    """Encode cursor for API response."""
    data = json.dumps({'id': order_id, 'date': str(order_date)})
    return base64.b64encode(data.encode()).decode()

def decode_cursor(cursor):
    """Decode cursor from API request."""
    data = json.loads(base64.b64decode(cursor).decode())
    return data['id'], data['date']

# API response
{
    "data": [...],
    "next_cursor": "eyJpZCI6IDEyMzQ1LCAiZGF0ZSI6ICIyMDI0LTAxLTE1In0="
}

Keyset Pagination Advantages

Advantage Description
Consistent performance Always scans same number of rows
No skipped rows No data changes between pages
Index-friendly Uses index for both seek and sort
Scalable Works well with millions of rows

Offset vs Keyset Comparison

Offset vs Keyset: When to Use Each

Both approaches have their place. Understanding the trade-offs helps you choose the right one.

Direct Comparison

Feature OFFSET/LIMIT Keyset/Cursor
Performance Degrades with depth Constant
Jump to page Yes (any page) No (sequential only)
Random access Yes No
Consistent results No (may shift) Yes
Index usage Partial Full
Complexity Simple Moderate
UI support Page numbers Infinite scroll

When to Use OFFSET/LIMIT

-- Use OFFSET when:
-- 1. Users need to jump to specific pages
-- 2. Dataset is small (< 100K rows)
-- 3. Page numbers are required in UI
-- 4. Data doesn't change frequently

-- Example: Admin dashboard with page numbers
SELECT * FROM orders 
ORDER BY order_date DESC 
LIMIT 25 OFFSET 50;  -- Page 3 of admin list

When to Use Keyset

-- Use Keyset when:
-- 1. Large datasets (millions of rows)
-- 2. Infinite scroll UI
-- 3. Real-time data (frequent inserts)
-- 4. API pagination (cursor-based)

-- Example: Social media feed
SELECT * FROM posts 
WHERE created_at < :last_seen_time 
ORDER BY created_at DESC 
LIMIT 20;

Hybrid Approach

-- Use OFFSET for first few pages, keyset for deep pages

-- Page 1-100: Use OFFSET (fast enough)
SELECT * FROM orders 
ORDER BY order_date DESC 
LIMIT 25 OFFSET 0;  -- Page 1

-- Page 100+: Switch to keyset
SELECT * FROM orders 
WHERE order_date < :last_date_seen 
ORDER BY order_date DESC 
LIMIT 25;

Performance Benchmarks

Dataset: 10 million rows, page size 20

OFFSET Pagination:
  Page 1:     2ms
  Page 100:   15ms
  Page 1000:  120ms
  Page 10000: 1.2s
  Page 50000: 6.0s

Keyset Pagination:
  Page 1:     2ms
  Page 100:   2ms
  Page 1000:  2ms
  Page 10000: 2ms
  Page 50000: 2ms

Keyset maintains constant time!

Decision Matrix

Scenario Recommendation
Admin panel, < 100K rows OFFSET
Social media feed Keyset
API with cursors Keyset
E-commerce listing, < 50K OFFSET
E-commerce listing, > 1M Keyset
Search results OFFSET
Real-time chat history Keyset

When to Use Each Approach

Practical Guidelines

OFFSET/LIMIT: Best For

-- 1. Small to medium datasets
SELECT * FROM products 
WHERE category = 'electronics' 
ORDER BY name 
LIMIT 20 OFFSET 0;
-- Only 500 products in category, OFFSET is fine

-- 2. When users need page numbers
-- "Page 1 of 25" in the UI
SELECT COUNT(*) FROM products WHERE category = 'electronics';  -- 500 total
-- 500 / 20 = 25 pages

-- 3. When data is relatively static
-- Reference data, archived orders, etc.

-- 4. Simple implementation
-- Easy to understand and debug

Keyset: Best For

-- 1. Large, continuously growing datasets
SELECT * FROM events 
WHERE id > :last_event_id 
ORDER BY id 
LIMIT 50;
-- Millions of events, always fast

-- 2. Infinite scroll / real-time feeds
SELECT * FROM messages 
WHERE created_at < :last_message_time 
ORDER BY created_at DESC 
LIMIT 20;
-- New messages arrive constantly

-- 3. API pagination
-- "Get next page" instead of "Go to page 5"

-- 4. Performance-critical paths
-- Every millisecond matters

Example: E-commerce Site

-- Homepage: Featured products (small set, OFFSET fine)
SELECT * FROM products 
WHERE featured = true 
ORDER BY popularity DESC 
LIMIT 12 OFFSET 0;

-- Category page: All products (medium set, OFFSET fine)
SELECT * FROM products 
WHERE category_id = 5 
ORDER BY created_at DESC 
LIMIT 24 OFFSET 0;

-- Search results: All products (large set, consider keyset)
SELECT * FROM products 
WHERE name ILIKE '%wireless%' 
ORDER BY relevance DESC, id 
LIMIT 24 OFFSET 0;
-- For deep pagination in search, use keyset

Migration Strategy

-- If you're currently using OFFSET and hitting performance issues:

-- Step 1: Add cursor support to your API
-- Step 2: Keep OFFSET for backward compatibility
-- Step 3: New clients use cursor-based API
-- Step 4: Deprecate OFFSET API when migration complete

-- Example: Dual pagination support
-- GET /api/orders?page=2&page_size=20           (OFFSET)
-- GET /api/orders?cursor=abc123&limit=20        (KEYSET)

Practice Problems

0/3solved
Basic OFFSET Pagination
OFFSET/LIMIT

Write a query to get the 3rd page of orders (10 per page), sorted by order_date DESC.

Example:

Input: Table orders with columns: id, customer_id, total, order_date

Output: Query returning orders 21-30

Page 3 = OFFSET (3-1)*10 = 20, LIMIT 10

Solution
```sql
SELECT id, customer_id, total, order_date
FROM orders
ORDER BY order_date DESC
LIMIT 10 OFFSET 20;
```

Edge Cases:

  • Empty result set
  • Fewer than 20 rows total
Keyset Pagination
Cursor-based

Rewrite the pagination query using keyset pagination. The last seen order has id=500 and order_date='2024-06-15'.

Example:

Input: last_id=500, last_date='2024-06-15', page_size=10

Output: Query returning orders after the cursor

Use WHERE to filter rows after the cursor position

Solution
```sql
SELECT id, customer_id, total, order_date
FROM orders
WHERE (order_date, id) < ('2024-06-15', 500)
ORDER BY order_date DESC, id DESC
LIMIT 10;
```

Edge Cases:

  • Cursor at beginning of table
  • Cursor at end of table
Total Count with Pagination
Pagination + Count

Write a query that returns both the paginated results AND the total count of matching rows in a single query.

Example:

Input: status='pending', page=2, page_size=10

Output: Results + total_count column

Use window function COUNT(*) OVER() to get total without separate query

Solution
```sql
SELECT id, customer_id, total, order_date,
       COUNT(*) OVER() as total_count
FROM orders
WHERE status = 'pending'
ORDER BY order_date DESC
LIMIT 10 OFFSET 10;
```

Edge Cases:

  • Zero matching rows
  • Exactly 10 matching rows

Quiz

1. Why does OFFSET-based pagination become slow with large offsets?

Question 1 options

2. What is keyset (cursor-based) pagination?

Question 2 options

3. When should you prefer OFFSET/LIMIT over keyset pagination?

Question 3 options

4. What is the primary purpose of SQL Pagination?

Question 4 options

Flashcards

Question

What is the formula for OFFSET/LIMIT pagination?

Answer

For page P with page size S: OFFSET = (P - 1) × S, LIMIT = S. Example: Page 3 with 10 items per page = OFFSET 20, LIMIT 10.

Question

What is keyset pagination and why is it faster?

Answer

Keyset pagination uses WHERE id > last_seen_id (or similar) instead of OFFSET. It's faster because the database uses the index to seek directly to the cursor position, scanning only the rows needed for the page, regardless of how deep you are.

Question

How do you get total count with pagination in a single query?

Answer

Use COUNT(*) OVER() window function: SELECT *, COUNT(*) OVER() as total FROM table WHERE ... LIMIT 10 OFFSET 20; This returns total_count alongside each row without a separate COUNT query.

Question

What is SQL Pagination?

Answer

SQL Pagination is a key concept in SQL databases.

Question

When to use SQL Pagination?

Answer

Use SQL Pagination when building production systems that require reliability, scalability, and maintainability.

Revision Notes

Key Takeaways

  • 1.OFFSET/LIMIT is simple but slow for large offsets
  • 2.Keyset pagination uses WHERE to seek, maintaining constant performance
  • 3.Use COUNT(*) OVER() for total count without extra query
  • 4.Choose OFFSET for page numbers, keyset for infinite scroll
  • 5.Large offsets cause linear performance degradation

Interview Tips

  • Explain why OFFSET is slow with large values
  • Demonstrate keyset pagination with a concrete example
  • Discuss trade-offs between the two approaches
  • Mention cursor encoding for API design
  • Show how to get total count alongside paginated results

Cheat Sheet

SQL Pagination Cheat Sheet

OFFSET/LIMIT

-- Page P, size S
SELECT * FROM table ORDER BY col LIMIT S OFFSET (P-1)*S;

-- Page 3, size 10:
SELECT * FROM orders ORDER BY id LIMIT 10 OFFSET 20;

Keyset/Cursor

-- First page
SELECT * FROM orders ORDER BY id LIMIT 10;
-- Use last id as cursor

-- Next page
SELECT * FROM orders WHERE id > :cursor ORDER BY id LIMIT 10;

Total Count (Single Query)

SELECT *, COUNT(*) OVER() as total
FROM orders WHERE status = 'pending'
ORDER BY order_date DESC LIMIT 10 OFFSET 0;

Performance

  • OFFSET: O(n) — degrades with depth
  • Keyset: O(log n + k) — constant time

When to Use

Use Case Approach
Page numbers, < 100K rows OFFSET
Infinite scroll, > 1M rows Keyset
APIs Keyset (cursors)
Admin panels OFFSET