You now have every piece of a query but one: deciding how many rows you want to receive. With the 20 rows of products it looks irrelevant, but the day you connect to a production table with fifty million records, a bare SELECT * FROM orders; can bring your terminal down, saturate the network and get a systems administrator looking for you with a face like thunder. LIMIT is the first line of defence. And it's also the basis of pagination, one of those problems that look solved in two minutes and that on large tables become a classic headache. In this lesson you'll learn both: the everyday use and the serious one.

Contents

  1. LIMIT: capping the result
  2. Why LIMIT without ORDER BY isn't deterministic
  3. OFFSET and classic pagination
  4. The two problems of OFFSET on large tables
  5. Cursor or keyset pagination
  6. FETCH FIRST n ROWS ONLY: the standard form
  7. Typical use cases
  8. LIMIT in the logical execution order
  9. Common Mistakes and Tips
  10. Exercises
  11. Conclusion

  1. LIMIT: capping the result

LIMIT n states how many rows, at most, you want to receive. It's written at the end of the query, after ORDER BY:

SELECT id, name, price
FROM products
ORDER BY price DESC
LIMIT 5;
id name price
15 Ceremonial matcha green tea 30 g 22.00
6 Aloe vera face cream 50 ml 18.90
20 Spirulina capsules 120 units 16.40
8 Almond body oil 200 ml 14.25
13 Soy wax candles (pack of 2) 13.75

The five most expensive products in the catalogue. This pattern —sort and trim— is the canonical way of answering any "top N" question.

LIMIT's behaviour in the edge cases:

How it's written What it returns
LIMIT 5 At most 5 rows
LIMIT 100 over products The 20 there are. LIMIT is a maximum, not a demand
LIMIT 0 No rows, but the headers yes. Useful for inspecting a query's column types without fully running it
LIMIT ALL All of them. Equivalent to no LIMIT; handy for building queries programmatically
LIMIT NULL All of them, the same as LIMIT ALL
LIMIT -1 ERROR: LIMIT must not be negative

The most frequent day-to-day use doesn't even carry an ORDER BY: it's taking a look at a table you don't know.

SELECT * FROM order_lines LIMIT 5;
id order_id product_id quantity unit_price discount
1 1 1 2 11.95 0.00
2 1 2 3 3.90 0.00
3 1 14 2 3.25 0.00
4 2 6 1 17.50 0.00
5 2 9 2 4.60 0.00

Here SELECT * and the absence of ORDER BY are perfectly legitimate: you don't want particular rows, you want to see what the data looks like. It's exploration, not a report.

Professional habit: when you connect to a database you don't know, write LIMIT 10 before writing the rest of the query. It costs eight characters and saves you the embarrassment of locking up a production session.

  1. Why LIMIT without ORDER BY isn't deterministic

We pick up the warning from 02-05, because with LIMIT it becomes far more dangerous.

SELECT id, name, price FROM products LIMIT 3;
id name price
1 Extra virgin olive oil 500 ml 12.50
2 Organic brown rice 1 kg 3.90
3 Raw orange blossom honey 500 g 9.75

What exactly did you ask for? Any three rows. Not the first three by id, not the three cheapest: three arbitrary rows, whichever the engine had closest to hand. That today they turn out to be 1, 2 and 3 is a coincidence of the physical order.

The difference from a query without LIMIT is subtle but crucial:

Query Without ORDER BY
SELECT ... FROM products Returns all 20 rows, in an unpredictable order. The set is correct; only the order is uncertain
SELECT ... FROM products LIMIT 3 Returns three unpredictable rows. The set itself is uncertain

In the first case, sorting the result in your application fixes the problem. In the second there's no possible fix: you've lost 17 rows and you don't know which.

Rule: LIMIT without ORDER BY is only acceptable for exploring. As soon as the result is used for anything, the ORDER BY is mandatory, and it has to be deterministic (ending in a unique column, as you saw in 02-05).

  1. OFFSET and classic pagination

OFFSET m discards the first m rows of the result before applying LIMIT. Combined, they let you walk a large result in chunks:

Page N  →  LIMIT size OFFSET (N - 1) * size

Let's see it with GreenStore's catalogue sorted by price descending and pages of five products.

Page 1LIMIT 5 OFFSET 0:

SELECT id, name, price
FROM products
ORDER BY price DESC, id
LIMIT 5 OFFSET 0;
id name price
15 Ceremonial matcha green tea 30 g 22.00
6 Aloe vera face cream 50 ml 18.90
20 Spirulina capsules 120 units 16.40
8 Almond body oil 200 ml 14.25
13 Soy wax candles (pack of 2) 13.75

Page 2LIMIT 5 OFFSET 5:

id name price
1 Extra virgin olive oil 500 ml 12.50
10 Concentrated eco laundry detergent 1 L 11.20
12 Reusable cotton bags (pack of 5) 9.90
3 Raw orange blossom honey 500 g 9.75
7 Rosemary solid shampoo 80 g 8.40

Page 3LIMIT 5 OFFSET 10:

id name price
19 Natural stick deodorant 50 g 7.80
11 Loofah scrubber (pack of 3) 5.50
17 Cold-pressed orange juice 1 L 5.40
16 Ginger kombucha 750 ml 4.95
9 Calendula lip balm 15 ml 4.60

And the fourth and last would close with products 2, 18, 14, 4 and 5.

A summary of the arithmetic:

Page OFFSET LIMIT Products returned
1 0 5 15, 6, 20, 8, 13
2 5 5 1, 10, 12, 3, 7
3 10 5 19, 11, 17, 16, 9
4 15 5 2, 18, 14, 4, 5

Important details:

  • The ORDER BY is mandatory. Without it, each page is computed over a different order and you could see the same product on two pages and another on none.
  • The ORDER BY must be deterministic. That's why we've written ORDER BY price DESC, id: if two products cost the same, the tie-break by id guarantees they always fall on the same page.
  • OFFSET without LIMIT is valid: OFFSET 15 returns the rows from the 16th onwards.
  • OFFSET larger than the total returns zero rows, with no error. OFFSET 100 over products gives nothing.

  1. The two problems of OFFSET on large tables

This pagination works, it's the one everybody learns, and it has two serious flaws that only show up when the data grows.

4.1. The cost grows with the page number

OFFSET m doesn't skip the first m rows: it computes them and throws them away. The engine has no way of guessing which row is number 500,001 without having produced the previous 500,000.

Page Rows the engine produces Rows it hands you
1 20 20
50 1,000 20
500 10,000 20
50,000 1,000,000 20

The last page of a large listing can take hundreds of times longer than the first. It's a known pattern: users complain that "the site is slow at the end of the catalogue", and the cause is right here. With EXPLAIN ANALYZE (module 8) you can see it perfectly clearly in the Limit node, which reports how many rows it discarded.

4.2. The data moves between one page and the next

This one is worse, because it isn't slow: it's wrong.

Imagine a user is looking at page 1 of the catalogue sorted by price descending and, at that very moment, somebody adds a €25 product (more expensive than the matcha). When the user hits "next":

Moment What happens
Page 1 (before) 15, 6, 20, 8, 13
A new €25.00 product is inserted The whole listing shifts one position
Page 2 (OFFSET 5) Position 6 is now held by product 13, which was already on page 1

The user sees product 13 twice. And with a deletion the opposite happens: a row disappears without ever having been shown.

Change in the data Symptom for the user
A row is inserted before the current page They see a repeated row
A row before the current page is deleted A row gets skipped and they never see it

Each page is an independent query, run at a different moment and over a different state of the table. OFFSET has no memory of what it already showed you.

When can you live with OFFSET?

Scenario Is OFFSET acceptable?
An internal dashboard with a few thousand rows Yes, no problem
Data that doesn't change during the session (a historical report) Yes
You need to "go to page 47" directly Yes: it's the only thing that allows it
A public catalogue with millions of rows and constant writes No
Infinite scroll in a mobile app No
Exporting a whole table in chunks No: use keyset

  1. Cursor or keyset pagination

The alternative consists of no longer saying "skip 500,000 rows" and starting to say "give me what comes after this". Instead of a position, you remember the last value seen.

Let's go back to our catalogue. Page 1 ended with product 13, at €13.75. Page 2 is requested like this:

SELECT id, name, price
FROM products
WHERE price < 13.75
ORDER BY price DESC, id
LIMIT 5;
id name price
1 Extra virgin olive oil 500 ml 12.50
10 Concentrated eco laundry detergent 1 L 11.20
12 Reusable cotton bags (pack of 5) 9.90
3 Raw orange blossom honey 500 g 9.75
7 Rosemary solid shampoo 80 g 8.40

Identical to page 2 from section 3, but obtained without discarding anything: the engine goes straight to the rows that satisfy price < 13.75.

What happens if there are ties. If two products cost exactly €13.75, WHERE price < 13.75 would skip the second. The solution is to compare the complete tuple of the sort columns, something SQL allows:

SELECT id, name, price
FROM products
WHERE (price, id) < (13.75, 13)
ORDER BY price DESC, id DESC
LIMIT 5;

The comparison (price, id) < (13.75, 13) is lexicographic: it holds if price < 13.75, or if price = 13.75 and additionally id < 13. It's exactly the condition "strictly after the last item seen" for that order. Careful with one detail: for the tuple comparison to line up, every ORDER BY column has to run in the same direction, which is why id DESC appears here.

A comparison of the two approaches:

Aspect OFFSET Keyset
Cost of page N Grows linearly with N Constant
Does it use an index? Only for sorting Yes, to position itself directly
Rows repeated or skipped if the data changes Yes No
Can it jump to page 47? Yes No: only "next" and "previous"
Can it show "page 3 of 120"? Yes Not without an extra query
Implementation complexity Trivial Medium: you have to carry the cursor around

In practice: if your interface is an infinite scroll or a "load more" button, keyset is always the right answer. If you need clickable page numbers, OFFSET is the only viable option and you have to accept its limits (or cap the number of navigable pages, which is what nearly every search engine does).

For keyset to perform you need an index over the sort columns, in this case (price, id). Without it, PostgreSQL has to sort the whole table anyway and you gain nothing. Indexes are module 8.

  1. FETCH FIRST n ROWS ONLY: the standard form

LIMIT is convenient, universally known... and it isn't standard SQL. MySQL introduced it, PostgreSQL adopted it and today nearly every engine understands it, but the official SQL:2008 syntax is another one:

SELECT id, name, price
FROM products
ORDER BY price DESC, id
OFFSET 0 ROWS
FETCH FIRST 5 ROWS ONLY;
id name price
15 Ceremonial matcha green tea 30 g 22.00
6 Aloe vera face cream 50 ml 18.90
20 Spirulina capsules 120 units 16.40
8 Almond body oil 200 ml 14.25
13 Soy wax candles (pack of 2) 13.75

Exactly the same result. PostgreSQL accepts both forms and treats them identically.

PostgreSQL also adds WITH TIES, which widens the result to include every row tied with the last one:

SELECT id, name, price
FROM products
ORDER BY price DESC
FETCH FIRST 5 ROWS WITH TIES;

On this data it returns the same 5 rows, because no two products share a price. But if there were three products at €13.75, WITH TIES would return 7 rows instead of 5: it's the correct way to do an honest "top 5" in a competition. WITH TIES requires ORDER BY and doesn't work with LIMIT, only with FETCH.

A comparison table across engines:

Engine Main syntax Alternatives
SQL:2008 standard OFFSET m ROWS FETCH FIRST n ROWS ONLY
PostgreSQL LIMIT n OFFSET m FETCH FIRST n ROWS ONLY, WITH TIES
MySQL / MariaDB LIMIT n OFFSET m LIMIT m, n (the arguments the other way round!)
SQLite LIMIT n OFFSET m LIMIT m, n
SQL Server 2012+ OFFSET m ROWS FETCH NEXT n ROWS ONLY SELECT TOP (n) ... (no offset)
Oracle 12c+ OFFSET m ROWS FETCH FIRST n ROWS ONLY
Oracle 11g and earlier WHERE ROWNUM <= n over a sorted subquery

Two traps in that table worth underlining:

  1. MySQL's LIMIT m, n reverses the meaning: the first number is the offset and the second the count. LIMIT 5, 10 in MySQL is LIMIT 10 OFFSET 5 in PostgreSQL. A query copied without reading it returns the wrong data.
  2. Oracle's ROWNUM is assigned before the ORDER BY. WHERE ROWNUM <= 5 ORDER BY price DESC returns five arbitrary rows and then sorts them: it isn't the top 5. You have to sort in a subquery and apply ROWNUM outside.

Which one should you use? In this course, LIMIT: it's what you'll see in 99 % of PostgreSQL code. If you write SQL that has to run on several engines, OFFSET ... FETCH FIRST ... ROWS ONLY is the safe bet.

  1. Typical use cases

7.1. Top N

You've already seen it with the most expensive products. Another variant, the five with the highest margin:

SELECT name,
       price,
       cost,
       price - cost AS margin
FROM products
WHERE active
ORDER BY margin DESC, id
LIMIT 5;
name price cost margin
Ceremonial matcha green tea 30 g 22.00 12.50 9.50
Aloe vera face cream 50 ml 18.90 9.50 9.40
Almond body oil 200 ml 14.25 7.10 7.15
Soy wax candles (pack of 2) 13.75 6.90 6.85
Reusable cotton bags (pack of 5) 9.90 4.30 5.60

Notice that the Spirulina capsules (margin €7.70, third in the catalogue) don't appear: WHERE active discards them before sorting. The logical order rules: first you filter, then you sort, and only at the end do you trim.

7.2. The latest records

SELECT id, customer_id, order_date, status, payment_method
FROM orders
ORDER BY order_date DESC, id DESC
LIMIT 3;
id customer_id order_date status payment_method
20 9 2026-02-21 pending cash_on_delivery
19 6 2026-02-09 paid card
18 5 2026-01-27 paid transfer

The last three orders received: the natural content of a "recent activity" panel.

7.3. The extreme record

SELECT id, name, last_name, signup_date
FROM customers
ORDER BY signup_date
LIMIT 1;
id name last_name signup_date
1 Lucía Martínez Soler 2025-01-10

GreenStore's first customer. This pattern —ORDER BY ... LIMIT 1— is equivalent to using MIN/MAX, but with an advantage: it gives you the whole row, not just the minimum value. With MIN(signup_date) you'd know the date, but not who it was. Aggregate functions arrive in lesson 04-04.

7.4. A quick sample for exploration

SELECT * FROM reviews LIMIT 3;
id product_id customer_id rating comment date
1 1 1 5 Excellent oil, intense flavour and beautiful packaging. 2025-03-15
2 2 1 4 Good rice, though it takes a little longer than usual. 2025-03-16
3 6 2 5 The cream leaves the skin very soft. I will buy it again. 2025-03-25

  1. LIMIT in the logical execution order

With LIMIT the cycle we started in 02-01 is complete. This is the module's definitive diagram:

flowchart LR
    A["1 · FROM<br/>source of the rows"] --> B["2 · WHERE<br/>filters rows"]
    B --> C["3 · SELECT<br/>projects and computes<br/>aliases are born here"]
    C --> D["3b · DISTINCT<br/>removes duplicates"]
    D --> E["4 · ORDER BY<br/>sorts the result"]
    E --> F["5 · LIMIT / OFFSET<br/>trims"]

LIMIT is always last. Consequences worth being clear about follow from that:

Question Answer
Does LIMIT 5 make WHERE examine only 5 rows? No. WHERE is applied to every row of the table
Does LIMIT 5 with ORDER BY sort only 5 rows? No. Logically everything is sorted and then trimmed
So LIMIT doesn't save any work? It does save work, but in the physical plan, not the logical one

That last point deserves a nuance. Even though logically everything is sorted, PostgreSQL is smarter than that: when it sees an ORDER BY ... LIMIT n with a small n, it uses an algorithm called a top-N heapsort, which keeps only the best n rows seen so far in memory instead of sorting the entire set. And if an index exists that already returns the rows in the requested order, it can read the first n and stop, without touching the rest of the table.

It's the perfect example of the distinction from 02-01: the logical order defines the meaning of the query, and the physical plan the optimizer picks can be radically more efficient as long as it produces the same result. You'll see it with your own eyes in module 8 with EXPLAIN.

Common Mistakes and Tips

  • LIMIT without ORDER BY in any query that isn't exploratory. It returns arbitrary rows and the problem can't be fixed afterwards.
  • A non-deterministic ORDER BY when paginating. If there are ties and you don't break them with a unique column, the same row can appear on two pages and another on none.
  • Copying LIMIT 10, 20 from a MySQL example. In MySQL it means "20 rows starting at the 11th"; in PostgreSQL it isn't even valid syntax.
  • Believing OFFSET skips rows for free. It produces them and throws them away. On page 5,000 you'll notice.
  • Paginating with OFFSET over data that changes. Repeated rows and skipped rows, with no error to give it away.
  • Applying keyset with an ORDER BY that doesn't match the WHERE condition. The columns must be the same, in the same order and in the same direction.
  • Using LIMIT to "fix" a slow query. If the query is slow for lack of an index, LIMIT can still be slow: the engine has to find the rows before trimming them.
  • Expecting LIMIT 1 to replace a well-written WHERE. Returning any old row among those that meet the condition is rarely what you want.
  • Tip: LIMIT first, query afterwards. When exploring an unfamiliar database, write it before anything else.
  • Tip: LIMIT 0 is useful. It gives you the columns and their types without fetching data, ideal for checking the shape of a complex query.
  • Tip: for an honest "top N", consider FETCH FIRST n ROWS WITH TIES. Cutting at exactly number 5 when 5 and 6 are tied is misleading.

Exercises

Exercise 1

Customer support needs a panel with the five oldest orders that are neither delivered nor cancelled, showing id, customer_id, order_date, status and shipping_cost. Write the query and explain how many rows it actually returns and why.

Exercise 2

Build the second page of a customer listing sorted by country and, within each country, by surname, with pages of four customers. Write it first with OFFSET and then with keyset pagination. Explain what information the application needs in each case to request the next page.

Exercise 3

The purchasing team wants the three order lines with the highest amount. Write the query using the standard FETCH FIRST syntax, and state what would change if you used WITH TIES. Then answer: why can't this query tell you which product is involved?

Solutions

Solution 1

SELECT id,
       customer_id,
       order_date,
       status,
       shipping_cost
FROM orders
WHERE status <> 'delivered'
  AND status <> 'cancelled'
ORDER BY order_date, id
LIMIT 5;
id customer_id order_date status shipping_cost
16 4 2025-12-19 shipped 4.95
17 7 2026-01-13 shipped 9.90
18 5 2026-01-27 paid 4.95
19 6 2026-02-09 paid 4.95
20 9 2026-02-21 pending 12.50

5 rows, which turn out to be all the ones meeting the condition: of the 20 orders, 14 are delivered and 1 cancelled, so only 5 remain in progress. The LIMIT 5 hasn't trimmed anything, and that's normal behaviour: LIMIT is a maximum, not a promise.

The reasoning by the logical order: FROM brings 20 rows → WHERE leaves 5 → SELECT projects → ORDER BY order_date, id sorts them from oldest to most recent → LIMIT 5 discards none. If ten new orders come in tomorrow, the query will still return the five oldest without changing a comma.

In module 4 you'll write that condition more cleanly as WHERE status NOT IN ('delivered', 'cancelled').

Solution 2

With OFFSET, page 2 (customers 5 to 8 of the listing):

SELECT id, name, last_name, city, country
FROM customers
ORDER BY country, last_name, id
LIMIT 4 OFFSET 4;

The complete listing sorted by country, last_name starts like this: Dubois (9), Moreau (10), Almeida Nunes (8), Moreira Costa (7) —page 1—, and continues:

id name last_name city country
5 Ana Belmonte Roca Barcelona Spain
13 Núria Bosch Ferrer Barcelona Spain
15 Inés Carrasco Vega Valencia Spain
2 Carlos Ferrer Ibáñez Valencia Spain

With keyset pagination, starting from the last item of page 1 —Sofia Moreira Costa, country Portugal, surname Moreira Costa, id 7—:

SELECT id, name, last_name, city, country
FROM customers
WHERE (country, last_name, id) > ('Portugal', 'Moreira Costa', 7)
ORDER BY country, last_name, id
LIMIT 4;

It returns exactly the same four rows.

What the application needs in each case:

Approach What it carries between requests
OFFSET Just a number: the current page. Easy to put in a URL (?page=2) and it allows jumping to any page
Keyset The sort values of the last row shown (here country, last_name and id). They're stored in a "cursor" that the application sends back when requesting the next page

Notice two details of the keyset: the WHERE's tuple contains exactly the same columns as the ORDER BY and in the same order, and id is included as the last column precisely to break ties between two customers who shared country and surname. Without that id, a repeated surname would cause a customer to be lost between one page and the next.

Solution 3

SELECT id,
       order_id,
       quantity,
       unit_price,
       discount,
       ROUND(quantity * unit_price * (1 - discount), 2) AS amount
FROM order_lines
ORDER BY quantity * unit_price * (1 - discount) DESC, id
FETCH FIRST 3 ROWS ONLY;
id order_id quantity unit_price discount amount
28 12 2 22.00 0.00 44.00
18 8 3 12.50 0.05 35.63
24 10 2 18.90 0.10 34.02

What would change with WITH TIES. Nothing, in this case: the fourth line is worth €26.73, a long way from the third's €34.02, so there's no tie to drag in. But it's worth noting that WITH TIES can't carry an artificial tie-break in the ORDER BY: if you leave the trailing , id, every row has a unique sort value and there will never be ties to include. For WITH TIES to be of any use you have to sort only by the real criterion:

ORDER BY quantity * unit_price * (1 - discount) DESC
FETCH FIRST 3 ROWS WITH TIES;

On this data it returns the same 3 rows. If there were two lines tied at €34.02, it would return 4.

Why you can't tell which product is involved. Because order_lines stores product_id, a number, and the product's name lives in the products table. With this module's tools you can only query one table at a time: the most you can say is that line 28 corresponds to product 15. To write "Ceremonial matcha green tea 30 g" in the report you need to combine both tables, and that's exactly what starts in the next lesson.

Conclusion

You've closed the full cycle of a query:

  • LIMIT n caps the result at a maximum of n rows and is the first line of defence when exploring large or unfamiliar tables.
  • LIMIT without ORDER BY isn't deterministic: it returns arbitrary rows, and that damage can't be repaired afterwards.
  • OFFSET m discards the first m rows and enables the classic LIMIT size OFFSET (N-1)*size pagination, which demands a deterministic ORDER BY.
  • That pagination has two flaws on large tables: the cost grows with the page number, and insertions or deletions cause rows to repeat or be skipped.
  • Cursor or keyset paginationWHERE (columns) < (last values seen)— has constant cost and never drifts out of sync, at the price of not being able to jump to an arbitrary page.
  • FETCH FIRST n ROWS ONLY is the standard form, and WITH TIES widens the result with the tied rows. Each engine has its dialect, and MySQL's LIMIT m, n reverses the arguments.
  • LIMIT is step 5 and the last of the logical order; the optimizer, however, does take advantage of its presence with strategies such as the top-N heapsort.

And with this you close the whole of module 2. You know how to build a query from start to finish: choose its source with FROM, filter rows with WHERE, project and compute columns with SELECT and its aliases, remove repetitions with DISTINCT, impose an order with ORDER BY and trim the result with LIMIT. And you know in what logical order all of that happens, which is what has let you understand why an alias works in one place and not in another.

But notice the ceiling you've hit again and again: orders tells you customer_id = 9 and not "Camille Dubois"; order_lines tells you product_id = 15 and not "Ceremonial matcha green tea"; products tells you category_id = 2 and not "Natural cosmetics". Every one of your queries has looked at a single table, and the questions GreenStore really cares about —what each customer bought, which category bills the most, which sales rep closes the most orders, which products nobody has bought— live precisely in the relationships between tables that you drew in the diagram in 01-06. In module 3, Queries with Multiple Tables, you'll learn to walk those relationships with JOIN: the INNER JOIN for what matches on both sides, the LEFT and RIGHT for keeping what doesn't match —there the three customers with no orders and the three never-sold products will finally show up—, the FULL OUTER for both at once, the SELF JOIN for the reflexive relationships of employees and customers, and UNION, INTERSECT and EXCEPT for combining whole results. GreenStore's nine tables stop being nine islands.

SQL Course

Module 1: Introduction to SQL

Module 2: Basic SQL queries

Module 3: Working with multiple tables

Module 4: Advanced data filtering

Module 5: Data manipulation

Module 6: Advanced SQL functions

Module 7: Subqueries and nested queries

Module 8: Indexes and performance tuning

Module 9: Transactions and concurrency

Module 10: Advanced topics

Module 11: SQL in practice

Module 12: Final project

© Copyright 2026. All rights reserved