Limit and Offset for Pagination
Learn to paginate query results in PostgreSQL using LIMIT and OFFSET, with practical examples, edge cases, and performance tips. Perfect for developers building scalable APIs.
Focus: limit and offset for pagination
Ever stared at a 10,000-row table and screamed (internally) when your API dumped every single row into a JSON response? That's the pain this lesson kills. When your application grows beyond a handful of records, fetching everything at once is slow, wasteful, and a one-way ticket to a sluggish frontend. The remedy is pagination — splitting results into bite-sized pages — and the workhorses of pagination in PostgreSQL are the LIMIT and OFFSET clauses. By the end of this lesson, you'll slice query results like a pro, page through data with confidence, and know exactly when to reach for a more advanced tool.
The problem this lesson solves
Let's set the scene. You have a products table with 50,000 rows. Your app's home page needs to show the latest 20 products. If you run a plain SELECT * FROM products, PostgreSQL sends all 50,000 rows to your client — including data the user never sees. That's a waste of memory, bandwidth, and user patience.
Real-world symptoms of missing pagination:
- Slow API responses — the database churns out thousands of rows, most of which get discarded.
- Frontend lag — rendering 50,000 DOM elements freezes the browser.
- Database load spikes — every request that fetches everything spikes CPU and I/O.
Pagination solves this by fetching only the slice you need. Instead of one giant query, you run many small ones, each returning a page of results. The core tools for that in PostgreSQL are LIMIT (how many rows to return) and OFFSET (how many rows to skip). Simple, yes — but full of subtle traps that can silently degrade performance or, worse, show the user the wrong data.
Core concept / mental model
Think of a book. You don't read all 1,000 pages at once; you flip to a chapter, read a page or two (that's your LIMIT), and if you need earlier or later content, you skip ahead (that's your OFFSET). In SQL, the table is the book, and the result set is its pages.
Here's the official syntax, part of the SELECT statement's tail end:
SELECT column_list
FROM table_name
ORDER BY sort_columns
LIMIT row_count OFFSET skip_count;
Two critical details:
LIMITtells PostgreSQL the maximum number of rows to return.LIMIT 10returns up to 10 rows.OFFSETtells PostgreSQL to discard the firstskip_countrows before applyingLIMIT.OFFSET 20 LIMIT 10returns rows 21–30.
The mental model in one sentence: OFFSET positions the cursor, LIMIT sets the window size. Together they define a page: page number n with page size s becomes OFFSET (n-1)*s LIMIT s.
Pro tip:
OFFSET 0is optional — it's the default. WritingLIMIT 10is the same asLIMIT 10 OFFSET 0.
An important nuance: without an ORDER BY, PostgreSQL returns rows in an unpredictable order — the same query can produce different pages on different runs. Always pair LIMIT/OFFSET with a deterministic ORDER BY column (like an id or a timestamp).
How it works step by step
Let's break down exactly what PostgreSQL does under the hood when you run:
SELECT * FROM products ORDER BY id LIMIT 10 OFFSET 20;
- Full scan (or index scan): PostgreSQL reads the
productstable, typically using the primary key index for theORDER BY id. - Sort: rows are sorted by
id(if not already in index order). - Discard
OFFSETrows: the first 20 rows are dropped from the result set. - Take
LIMITrows: the next 10 sorted rows are returned to the client.
The crucial fact: PostgreSQL still reads (and often sorts) all rows before discarding the offset. This is the classic performance gotcha — see the Troubleshooting section.
Now, how do you translate page requests from your app? If a user asks for page 3 with 25 items per page:
LIMIT = 25OFFSET = (3 - 1) * 25 = 50
So the query becomes:
SELECT * FROM products ORDER BY id LIMIT 25 OFFSET 50;
That's the entire core logic. Simple, right? Now let's get our hands dirty.
Hands-on walkthrough
We'll use a toy products table. Open psql and run:
CREATE TABLE products (
id SERIAL PRIMARY KEY,
name TEXT NOT NULL,
price NUMERIC(10,2)
);
INSERT INTO products (name, price) VALUES
('Widget', 9.99),
('Gadget', 19.99),
('Gizmo', 29.99),
('Contraption', 39.99),
('Doohickey', 49.99),
('Thingamajig', 59.99),
('Whatsit', 69.99),
('Doodad', 79.99),
('Flux Capacitor', 89.99),
('Hoverboard', 99.99),
('Lightsaber', 109.99),
('Warp Core', 119.99);
Example 1: Basic LIMIT and OFFSET
SELECT id, name, price
FROM products
ORDER BY id
LIMIT 5;
Expected output:
id | name | price
----+---------------+-------
1 | Widget | 9.99
2 | Gadget | 19.99
3 | Gizmo | 29.99
4 | Contraption | 39.99
5 | Doohickey | 49.99
(5 rows)
Now fetch the next 5 rows:
SELECT id, name, price
FROM products
ORDER BY id
LIMIT 5 OFFSET 5;
Expected output:
id | name | price
----+-------------+-------
6 | Thingamajig | 59.99
7 | Whatsit | 69.99
8 | Doodad | 79.99
9 | Flux Capacitor | 89.99
10 | Hoverboard | 99.99
(5 rows)
Example 2: Pagination with page number and size
Let's write a query that returns page 3, with 4 items per page:
-- Page 3, 4 items per page → OFFSET = (3-1)*4 = 8, LIMIT = 4
SELECT id, name, price
FROM products
ORDER BY id
LIMIT 4 OFFSET 8;
Expected output:
id | name | price
----+-------------+-------
9 | Flux Capacitor | 89.99
10 | Hoverboard | 99.99
11 | Lightsaber | 109.99
12 | Warp Core | 119.99
(4 rows)
Example 3: Combine with WHERE
Pagination works with filters. Suppose you want the cheapest products, 3 per page:
SELECT id, name, price
FROM products
WHERE price < 100
ORDER BY price, id
LIMIT 3 OFFSET 3;
Expected output:
id | name | price
----+-------------+-------
4 | Contraption | 39.99
5 | Doohickey | 49.99
6 | Thingamajig | 59.99
(3 rows)
Notice we added id as a tiebreaker in ORDER BY — essential when price values repeat.
Example 4: Get the total count for pagination UI
Most apps need to show “Page 3 of 7”. That requires a count query:
SELECT count(*) FROM products WHERE price < 100;
Expected output:
count
-------
8
(1 row)
With count = 8 and page size = 3, you compute total pages as ceil(8/3) = 3.
Pro tip: Never use
LIMIT/OFFSETwithoutORDER BYfor pagination — page contents become nondeterministic and can show duplicate or missing rows when data changes between requests.
Compare options / when to choose what
LIMIT/OFFSET isn't the only way to page in PostgreSQL. Here's a comparison with two popular alternatives:
| Approach | How it works | Pros | Cons | Best when |
|---|---|---|---|---|
| LIMIT/OFFSET | Skip OFFSET rows, take LIMIT rows |
Simple, works with any ORDER BY, easy to jump to any page |
Slow on large offsets (still scans/sorts skipped rows); data changes can cause duplicates | Small tables, admin panels, prototyping, “jump to page N” UI |
| Keyset pagination (seek method) | Filter on the last seen value, e.g. WHERE (id, created_at) > (last_id, last_created_at) ORDER BY id, created_at LIMIT n |
O(1) performance regardless of depth; stable under data changes | Requires a unique sort key; no random page jumps; more complex queries | Infinite scroll, large tables, real-time feeds |
| Cursor-based (e.g. relay-style) | Encode opaque cursor in a token, decode server-side | Perfect for APIs; hides implementation details | More server-side code; not a pure SQL feature | Public APIs where clients shouldn't know about column values |
When to choose what:
- Choose LIMIT/OFFSET when your dataset is small or you need direct page-number navigation (like a classic “Page 2 of 10” widget).
- Switch to keyset pagination when you hit thousands of pages — query time starts to degrade linearly as
OFFSETgrows. - For public-facing APIs, consider cursor-based — it's more robust and hides complexity.
Troubleshooting & edge cases
1. Large OFFSET is slow
Symptom: Queries with OFFSET 100000 take ages.
Why: PostgreSQL still reads and sorts 100,000 rows before discarding them.
Fix: Switch to keyset pagination for deep pages, or limit pages with a “load more” pattern.
2. Negative or zero values
LIMIT 0 returns no rows (intentional). OFFSET with a negative number raises an error:
SELECT * FROM products LIMIT -1;
Error:
ERROR: LIMIT must not be negative
Always clamp your page number to >= 1 in application code.
3. Missing ORDER BY causes inconsistent pages
Without ORDER BY, PostgreSQL may return rows in any order (often insertion, but not guaranteed). If the table is vacuumed or updated, the same OFFSET can yield different rows. Always pair LIMIT/OFFSET with a deterministic ORDER BY.
4. Data changes between requests
If a row is inserted or deleted while the user flips pages, they might see duplicates or miss an item. For example, page 1 returns rows 1–10, then row 5 is deleted; page 2 (OFFSET 10) now skips 11 rows, so the user misses row 11. This is inherent to OFFSET — a keyset or cursor approach solves it if data stability matters.
5. OFFSET beyond the table size
OFFSET 1000 LIMIT 10 on a 100-row table simply returns an empty set — no error. That's fine, but make sure your app handles an empty page gracefully (show “no more results”).
What you learned & what's next
You now hold the key to efficient data fetching: LIMIT and OFFSET let you control exactly how many rows a query returns and where the cursor starts. You've seen how to combine them with ORDER BY and WHERE, how to compute page offsets from user input, and how to count results for pagination UI. You've also learned the critical caveat — large offsets degrade performance — and met the modern alternative, keyset pagination.
Next in the PostgreSQL Tutorial, you'll dive into indexes (or whatever the next lesson is) to make those ORDER BY and WHERE clauses lightning-fast. With pagination and indexing in your belt, you're well on your way to building queries that scale to millions of rows.
Practice recap
Try this: write a query that returns page 4 of your products table with 3 items per page. Then, create a WHERE clause to filter products under $100 and build the corresponding page 2 query. Finally, experiment with a large OFFSET (e.g., 100000) on a bigger table to see the performance drop — that'll make the switch to keyset pagination feel intuitive.
Common mistakes
- Running LIMIT/OFFSET without ORDER BY — results can appear in different order across runs, causing duplicate or missing items.
- Using a huge OFFSET (e.g., 100000) on a large table — PostgreSQL still scans and sorts all skipped rows, so queries get slower with page depth.
- Forgetting to clamp OFFSET to a non-negative value — a negative OFFSET throws an error and crashes your query.
- Assuming OFFSET beyond the table size raises an error — it simply returns zero rows, which can silently break pagination logic if not handled.
Variations
- Keyset pagination:
WHERE (id, created_at) > (last_id, last_created_at) ORDER BY id, created_at LIMIT nfor O(1) depth performance. - Cursor-based pagination (relay-style) using opaque tokens for public APIs — hides implementation details and ensures stable results.
- Using a simple
LIMITalone when you only need a top-N list (e.g., top 10 products) — no OFFSET needed for the first page.
Real-world use cases
- REST API listing endpoints (e.g., /api/products?page=2&limit=20) built on Node.js, Python, or Go, returning paginated JSON responses.
- Admin dashboards showing user registrations or orders in a paginated table with page numbers, powered by a classic OFFSET/LIMIT query.
- E-commerce category pages with 'load more' button fetching the next batch of products by incrementing an OFFSET counter.
Key takeaways
- LIMIT controls the number of rows returned; OFFSET skips rows before that — together they define a page.
- Always pair LIMIT/OFFSET with a deterministic ORDER BY (like a unique id) to ensure stable pagination.
- Page N with size S translates to
LIMIT S OFFSET (N-1)*S. - Large OFFSET values degrade performance because PostgreSQL still reads/sorts all skipped rows — use keyset or cursor pagination for deep pages.
- Use a separate
SELECT count(*)to compute total pages for UI controls, but be aware it scans the table each time. - Handle empty pages gracefully — OFFSET beyond the table size returns zero rows, not an error.
Keep learning
Related tutorials, quizzes, and articles for this topic.
Discussion
Questions, corrections, and tips help everyone reading this page.
0 comments
Add a comment
No comments yet — start the thread.