SQL (PostgreSQL, MySQL, SQLite) Module 2 – Filtering and sorting rows
ORDER BY, LIMIT and OFFSET
Sort SQL results with ORDER BY on several columns, decide where NULLs go in PostgreSQL, MySQL and SQLite, and page through rows with LIMIT and OFFSET safely.
What you will learn
- Sort by several columns and directions
- Choose where NULLs sort in PostgreSQL, MySQL and SQLite
- Retrieve the first N rows and page through a sorted result
Before you start
On this page
A table has no built-in order. The rows of a result arrive in whatever order the database finds convenient, which
depends on how the data happens to be stored and on the plan the database picks for the query. The PostgreSQL and
SQLite manuals both say so plainly: without ORDER BY, the order is not defined. So whenever the order matters, to a
person reading a list or to a program showing “the first ten”, the query has to ask for it.
Sorting with ORDER BY
ORDER BY comes after WHERE and lists one or more sort keys. Each key sorts in ascending order (ASC, smallest
first) unless you add DESC. Rows are sorted by the first key; only rows that tie on it are sorted by the second key,
and so on:
CREATE TABLE products (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL,
category TEXT NOT NULL,
price_paise INTEGER NOT NULL
);
INSERT INTO products VALUES
(1, 'Toor dal 1 kg', 'pulses', 16500),
(2, 'Basmati rice 5 kg', 'grains', 62000),
(3, 'Moong dal 1 kg', 'pulses', 14500),
(4, 'Sona masoori 10 kg', 'grains', 74000),
(5, 'Chana dal 1 kg', 'pulses', 11000),
(6, 'Wheat atta 10 kg', 'grains', 48000),
(7, 'Masoor dal 1 kg', 'pulses', 14500);
-- By category A to Z; inside a category, the most expensive first;
-- products with the same price by name.
SELECT category, price_paise, name
FROM products
ORDER BY category, price_paise DESC, name; Output
┌──────────┬─────────────┬────────────────────┐ │ category │ price_paise │ name │ ├──────────┼─────────────┼────────────────────┤ │ grains │ 74000 │ Sona masoori 10 kg │ │ grains │ 62000 │ Basmati rice 5 kg │ │ grains │ 48000 │ Wheat atta 10 kg │ │ pulses │ 16500 │ Toor dal 1 kg │ │ pulses │ 14500 │ Masoor dal 1 kg │ │ pulses │ 14500 │ Moong dal 1 kg │ │ pulses │ 11000 │ Chana dal 1 kg │ └──────────┴─────────────┴────────────────────┘
Recorded with sql.js 1.14.2 (SQLite 3.49.1) on macOS 26 arm64. To run it yourself: sqlite3 -box :memory: < sort_keys.sql
Runs on this device, in your browser. The first run downloads SQLite (about 0.7 MB), which is kept for the next runs.
Your run, in this browser
DESC belongs to the key just before it, not to the whole list: ORDER BY category, price_paise DESC sorts the
categories from A to Z and the prices inside each category from high to low. The two lentils at 14500 paise tie on
both of the first two keys, and the third key, name, decides between them.
A sort key can be any expression, a column that is not in the SELECT list, or the name you gave an output column
with AS. You may also see ORDER BY 2, meaning the second column of the output; it works in all three engines, but
it breaks silently when someone reorders the SELECT list, and MySQL’s manual marks it as deprecated.
Ties and the tie-breaker
When several rows have the same value for every sort key, the database may return them in any order, and that order can change between engines, versions or runs. Usually nobody notices, until a query takes only the first few rows:
CREATE TABLE products (id INTEGER PRIMARY KEY, name TEXT NOT NULL, price_paise INTEGER NOT NULL);
INSERT INTO products VALUES
(1, 'Cashews 500 g', 52000), (2, 'Saffron 1 g', 30000),
(3, 'Almonds 500 g', 45000), (4, 'Ghee 1 l', 65000),
(5, 'Pistachios 250 g', 30000), (6, 'Honey 500 g', 24000),
(7, 'Raisins 500 g', 18000), (8, 'Walnuts 500 g', 52000);
-- Six rows, to see what happens at the edge of a top five:
-- the fifth and sixth products cost the same.
SELECT id, name, price_paise
FROM products
ORDER BY price_paise DESC
LIMIT 6;
-- The top five, with id as the tie-breaker: the same five rows every time.
SELECT id, name, price_paise
FROM products
ORDER BY price_paise DESC, id
LIMIT 5; Output
┌────┬──────────────────┬─────────────┐ │ id │ name │ price_paise │ ├────┼──────────────────┼─────────────┤ │ 4 │ Ghee 1 l │ 65000 │ │ 1 │ Cashews 500 g │ 52000 │ │ 8 │ Walnuts 500 g │ 52000 │ │ 3 │ Almonds 500 g │ 45000 │ │ 2 │ Saffron 1 g │ 30000 │ │ 5 │ Pistachios 250 g │ 30000 │ └────┴──────────────────┴─────────────┘ ┌────┬───────────────┬─────────────┐ │ id │ name │ price_paise │ ├────┼───────────────┼─────────────┤ │ 4 │ Ghee 1 l │ 65000 │ │ 1 │ Cashews 500 g │ 52000 │ │ 8 │ Walnuts 500 g │ 52000 │ │ 3 │ Almonds 500 g │ 45000 │ │ 2 │ Saffron 1 g │ 30000 │ └────┴───────────────┴─────────────┘
Recorded with sql.js 1.14.2 (SQLite 3.49.1) on macOS 26 arm64. To run it yourself: sqlite3 -box :memory: < top_five.sql
Runs on this device, in your browser. The first run downloads SQLite (about 0.7 MB), which is kept for the next runs.
Your run, in this browser
Saffron and pistachios cost the same 30000 paise, so they share fifth place. SQLite happened to put saffron first, but
nothing promises that. Another engine, a new index or more data could make the same query return pistachios, and
two pages of a list could then both show, or both skip, the same product. The second query adds id, which is
unique, as the last key. With a tie-breaker that no two rows share, the order is complete and the top five is the
same every time.
PostgreSQL can instead keep every product that ties for the last place, with the standard clause
FETCH FIRST 5 ROWS WITH TIES in place of a LIMIT. Sorted by price_paise DESC alone, it returns the six rows of
the first query here; after ORDER BY price_paise DESC, id nothing ties any more, and it returns five.
Where NULLs go
The engines do not agree on where missing values belong in a sort:
- SQLite and MySQL treat
NULLas smaller than any value: first in ascending order, last in descending order. - PostgreSQL treats
NULLas larger than any value: last in ascending order, first in descending order.
You can choose the position yourself. Standard SQL adds NULLS FIRST or NULLS LAST after a sort key, and
PostgreSQL and SQLite accept it. MySQL 8.4 does not, but sorting by price_paise IS NULL first does the same job
there, because that test is 0 for the priced rows and 1 for the others:
CREATE TABLE products (id INTEGER PRIMARY KEY, name TEXT NOT NULL, price_paise INTEGER);
INSERT INTO products VALUES
(1, 'Jaggery 1 kg', 9000), (2, 'Kokum 200 g', NULL), -- not priced yet
(3, 'Tamarind 500 g', 12000), (4, 'Mango pickle 400 g', 15000);
-- SQLite's default: NULL sorts before every other value.
SELECT name, price_paise FROM products ORDER BY price_paise;
-- Standard SQL, accepted by SQLite and PostgreSQL but not by MySQL.
SELECT name, price_paise FROM products ORDER BY price_paise NULLS LAST;
-- A form that also works in MySQL: sort by "is it missing?" first (0 before 1).
SELECT name, price_paise FROM products ORDER BY price_paise IS NULL, price_paise; Output
┌────────────────────┬─────────────┐ │ name │ price_paise │ ├────────────────────┼─────────────┤ │ Kokum 200 g │ │ │ Jaggery 1 kg │ 9000 │ │ Tamarind 500 g │ 12000 │ │ Mango pickle 400 g │ 15000 │ └────────────────────┴─────────────┘ ┌────────────────────┬─────────────┐ │ name │ price_paise │ ├────────────────────┼─────────────┤ │ Jaggery 1 kg │ 9000 │ │ Tamarind 500 g │ 12000 │ │ Mango pickle 400 g │ 15000 │ │ Kokum 200 g │ │ └────────────────────┴─────────────┘ ┌────────────────────┬─────────────┐ │ name │ price_paise │ ├────────────────────┼─────────────┤ │ Jaggery 1 kg │ 9000 │ │ Tamarind 500 g │ 12000 │ │ Mango pickle 400 g │ 15000 │ │ Kokum 200 g │ │ └────────────────────┴─────────────┘
Recorded with sql.js 1.14.2 (SQLite 3.49.1) on macOS 26 arm64. To run it yourself: sqlite3 -box :memory: < null_order.sql
Runs on this device, in your browser. The first run downloads SQLite (about 0.7 MB), which is kept for the next runs.
Your run, in this browser
The second and third queries give the same order; the third form is the one to use in MySQL. Writing the position of
NULLs explicitly also documents the decision for the next reader.
Taking the first rows: LIMIT
LIMIT n keeps the first n rows of the sorted result, and OFFSET m skips m rows before it starts counting. The
order of work matters: the database filters, then sorts, then skips, then takes.
How LIMIT 3 OFFSET 3 takes the second page of a sorted result
Text description of the diagram
The diagram shows the steps from top to bottom.
- WHERE keeps the rows; in this example all eight products.
- ORDER BY name sorts them: Amla, Bael, Chikoo, Dates and so on.
- OFFSET 3 skips the first three rows: Amla, Bael and Chikoo.
- LIMIT 3 returns the next three rows: Dates, Elaichi and Figs.
The result is a page of three rows; Guava and Honey, which come after them, are left out. OFFSET skips rows only after they have been found and sorted, which is why a large OFFSET still costs the database the work of the rows it skips.
LIMIT without ORDER BY returns an arbitrary few rows, which is fine for a quick look at a table and wrong almost
everywhere else.
Paging through results
A list shown page by page is ORDER BY with LIMIT and OFFSET. With s rows a page, page n is
LIMIT s OFFSET (n - 1) * s:
CREATE TABLE products (id INTEGER PRIMARY KEY, name TEXT NOT NULL);
INSERT INTO products VALUES
(1, 'Amla'), (2, 'Bael'), (3, 'Chikoo'), (4, 'Dates'),
(5, 'Elaichi'), (6, 'Figs'), (7, 'Guava'), (8, 'Honey');
-- Three products a page. Page 2 skips the first page's 3 rows.
SELECT id, name FROM products ORDER BY name LIMIT 3 OFFSET 3;
-- The last page holds what is left.
SELECT id, name FROM products ORDER BY name LIMIT 3 OFFSET 6; Output
┌────┬─────────┐ │ id │ name │ ├────┼─────────┤ │ 4 │ Dates │ │ 5 │ Elaichi │ │ 6 │ Figs │ └────┴─────────┘ ┌────┬───────┐ │ id │ name │ ├────┼───────┤ │ 7 │ Guava │ │ 8 │ Honey │ └────┴───────┘
Recorded with sql.js 1.14.2 (SQLite 3.49.1) on macOS 26 arm64. To run it yourself: sqlite3 -box :memory: < pages.sql
Runs on this device, in your browser. The first run downloads SQLite (about 0.7 MB), which is kept for the next runs.
Your run, in this browser
The first query is page 2; the last page simply holds fewer rows. SQLite and MySQL also accept a shorter form with two numbers and a comma, which is easy to misread:
CREATE TABLE products (id INTEGER PRIMARY KEY, name TEXT NOT NULL);
INSERT INTO products VALUES
(1, 'Amla'), (2, 'Bael'), (3, 'Chikoo'), (4, 'Dates'),
(5, 'Elaichi'), (6, 'Figs'), (7, 'Guava'), (8, 'Honey');
-- SQLite and MySQL also accept LIMIT with two numbers and a comma.
SELECT id, name FROM products ORDER BY name LIMIT 2, 3; Output
┌────┬─────────┐ │ id │ name │ ├────┼─────────┤ │ 3 │ Chikoo │ │ 4 │ Dates │ │ 5 │ Elaichi │ └────┴─────────┘
Recorded with sql.js 1.14.2 (SQLite 3.49.1) on macOS 26 arm64. To run it yourself: sqlite3 -box :memory: < limit_comma.sql
Runs on this device, in your browser. The first run downloads SQLite (about 0.7 MB), which is kept for the next runs.
Your run, in this browser
LIMIT 2, 3 skipped two rows and returned three: the first number is the offset and the second the count, the
reverse of the order in which LIMIT … OFFSET … names them. The SQLite manual itself calls this counter-intuitive and
recommends the OFFSET keyword, and PostgreSQL’s LIMIT has no comma form at all, so LIMIT 3 OFFSET 2 is the form
to write.
Three more details differ between the engines:
- Skipping rows and returning all the rest. PostgreSQL writes
LIMIT ALL OFFSET mand SQLiteLIMIT -1 OFFSET m, since a negative limit means no limit there. MySQL has no keyword for it: its manual suggests a very large count,LIMIT m, 18446744073709551615. - What the count and the offset may be. PostgreSQL and SQLite accept expressions that give a whole number. MySQL
wants non-negative whole-number constants, or
?placeholders in a prepared statement. - Rows tied for the last place. Only PostgreSQL, of the three, can add them, with
FETCH FIRST n ROWS WITH TIES.
Paging with OFFSET has a cost that grows with the page number: to show page 500, the database still has to work
through the rows of the 499 pages before it, only to throw them away. It also shifts when rows are added while someone
pages through. For long lists, the performance module shows keyset pagination, which remembers the last row of a
page instead of counting rows.
Text and numbers that sort strangely
Two sorting surprises come from the data rather than from the query:
CREATE TABLE items (name TEXT NOT NULL, size TEXT NOT NULL);
INSERT INTO items VALUES ('apple', '9'), ('Banana', '10'), ('cherry', '100'), ('Date', '25');
-- Text sorts by character codes: capital letters before small ones,
-- and '10' before '9', because '1' comes before '9'.
SELECT name, size FROM items ORDER BY name;
SELECT name, size FROM items ORDER BY size;
-- Fixes: sort without regard to case, and sort numbers as numbers.
SELECT name FROM items ORDER BY name COLLATE NOCASE;
SELECT name, size FROM items ORDER BY CAST(size AS INTEGER); Output
┌────────┬──────┐ │ name │ size │ ├────────┼──────┤ │ Banana │ 10 │ │ Date │ 25 │ │ apple │ 9 │ │ cherry │ 100 │ └────────┴──────┘ ┌────────┬──────┐ │ name │ size │ ├────────┼──────┤ │ Banana │ 10 │ │ cherry │ 100 │ │ Date │ 25 │ │ apple │ 9 │ └────────┴──────┘ ┌────────┐ │ name │ ├────────┤ │ apple │ │ Banana │ │ cherry │ │ Date │ └────────┘ ┌────────┬──────┐ │ name │ size │ ├────────┼──────┤ │ apple │ 9 │ │ Banana │ 10 │ │ Date │ 25 │ │ cherry │ 100 │ └────────┴──────┘
Recorded with sql.js 1.14.2 (SQLite 3.49.1) on macOS 26 arm64. To run it yourself: sqlite3 -box :memory: < text_sort.sql
Runs on this device, in your browser. The first run downloads SQLite (about 0.7 MB), which is kept for the next runs.
Your run, in this browser
- SQLite compares text with the
BINARYcollation, character code by character code, and capital letters come before small ones in that code.BananaandDatetherefore sort beforeapple.COLLATE NOCASEsorts the 26 English letters without regard to case. MySQL sorts text without regard to case by default, and PostgreSQL sorts by the column’s collation, which by default comes from the locale chosen when the database was created, so the same query can list these four names in different orders in different engines. - A number stored as text sorts like text:
'10'comes before'9'because the character1comes before9.CAST(size AS INTEGER)sorts the values as numbers. The better fix is to store numbers in a number column, which the module on data types explains.
Version note
NULLS FIRST and NULLS LAST arrived in SQLite 3.30.0; older SQLite versions report a syntax error, as MySQL 8.4
does. FETCH FIRST … WITH TIES is PostgreSQL syntax from the SQL standard; MySQL 8.4 and SQLite do not accept it.
The examples on this page ran with SQLite 3.49.1.
Key takeaways
- Without
ORDER BYthe order of rows is undefined; ask for an order whenever it matters. - Each sort key has its own direction,
ASCby default;DESCapplies only to the key before it. - End the sort keys with a unique column, such as the primary key, so that ties cannot change the result.
- SQLite and MySQL sort
NULLs first in ascending order, PostgreSQL last; useNULLS LASTorORDER BY x IS NULL, x. - Page
nofsrows isLIMIT s OFFSET (n - 1) * s; inLIMIT a, bthe first number is the offset.
Exercise
Exercise · Easy · SQL
Show the second page of a price list
A shop lists its products ten to a page, the most expensive first. Products with the same price are listed in alphabetical order of their names. The table products has the columns id, name and price_paise.
Write a query that returns the second page of that list: the id, name and price_paise of rows 11 to 20, sorted by price from the highest down, and by name from A to Z where prices are equal.
The checker compares the rows in order, so the sorting must be exactly as described. The sample tests use a table of 24 products (with equal prices right at the edges of the page), one of 15 (so the second page is short) and one of 6 (so the second page is empty).
Starter code · page_two.sql
-- Rows 11 to 20 of the price list: most expensive first, equal prices by name.
SELECT id, name, price_paise
FROM products
LIMIT 10; The sample tests · tests.yaml
# Sample tests: each one builds a products table, runs your query on it and compares the rows it returns in order.
tests:
- name: returns rows 11 to 20, with equal prices in name order
ordered: true
setup: |
CREATE TABLE products (id INTEGER PRIMARY KEY, name TEXT, price_paise INTEGER);
INSERT INTO products VALUES
(1, 'Saffron 2 g', 64000), (2, 'Ghee 1 l', 65000), (3, 'Cashews 500 g', 52000),
(4, 'Walnuts 500 g', 52000), (5, 'Almonds 500 g', 45000), (6, 'Pistachios 250 g', 42000),
(7, 'Honey 1 kg', 42000), (8, 'Cardamom 100 g', 38000), (9, 'Basmati rice 5 kg', 36000),
(10, 'Dates 1 kg', 30000), (11, 'Raisins 500 g', 30000), (12, 'Mustard oil 1 l', 30000),
(13, 'Tea 500 g', 28000), (14, 'Coffee 500 g', 28000), (15, 'Jaggery 2 kg', 21000),
(16, 'Toor dal 1 kg', 16500), (17, 'Moong dal 1 kg', 14500), (18, 'Masoor dal 1 kg', 14500),
(19, 'Poha 1 kg', 9000), (20, 'Sugar 1 kg', 9000), (21, 'Salt 1 kg', 2800),
(22, 'Rice flour 1 kg', 9000), (23, 'Besan 1 kg', 12000), (24, 'Turmeric 200 g', 7000);
expected:
columns: [id, name, price_paise]
rows:
- [12, Mustard oil 1 l, 30000]
- [11, Raisins 500 g, 30000]
- [14, Coffee 500 g, 28000]
- [13, Tea 500 g, 28000]
- [15, Jaggery 2 kg, 21000]
- [16, Toor dal 1 kg, 16500]
- [18, Masoor dal 1 kg, 14500]
- [17, Moong dal 1 kg, 14500]
- [23, Besan 1 kg, 12000]
- [19, Poha 1 kg, 9000]
- name: returns a short second page when there are only 15 products
ordered: true
setup: |
CREATE TABLE products (id INTEGER PRIMARY KEY, name TEXT, price_paise INTEGER);
INSERT INTO products VALUES
(1, 'Amla 1 kg', 12000), (2, 'Bael 1 kg', 8000), (3, 'Chikoo 1 kg', 9000),
(4, 'Dates 500 g', 15000), (5, 'Figs 250 g', 15000), (6, 'Guava 1 kg', 8000),
(7, 'Jamun 500 g', 11000), (8, 'Kiwi 3 pcs', 11000), (9, 'Lychee 500 g', 20000),
(10, 'Mango 1 kg', 20000), (11, 'Orange 1 kg', 9000), (12, 'Papaya 1', 6000),
(13, 'Pear 1 kg', 14000), (14, 'Plum 500 g', 14000), (15, 'Sweet lime 1 kg', 7000);
expected:
columns: [id, name, price_paise]
rows:
- [11, Orange 1 kg, 9000]
- [2, Bael 1 kg, 8000]
- [6, Guava 1 kg, 8000]
- [15, Sweet lime 1 kg, 7000]
- [12, Papaya 1, 6000]
- name: returns no rows when everything fits on the first page
ordered: true
setup: |
CREATE TABLE products (id INTEGER PRIMARY KEY, name TEXT, price_paise INTEGER);
INSERT INTO products VALUES
(1, 'Rock salt 1 kg', 6000), (2, 'Sesame oil 1 l', 32000), (3, 'Ragi flour 1 kg', 9500),
(4, 'Kokum 200 g', 9500), (5, 'Curry leaves', 1500), (6, 'Coconut 1', 4000);
expected:
columns: [id, name, price_paise]
rows: [] A hint
Sort first with two keys, ORDER BY price_paise DESC, name ASC, then take the page: page n of a list with s rows to a page is LIMIT s OFFSET (n - 1) * s, so the second page of ten is LIMIT 10 OFFSET 10.
Results of the sample tests
| Test | Result | Details |
|---|
What your code printed
The sample tests run on this device, in your browser (sql.js): nothing is sent to mysmartcopilot.com. The first run downloads SQLite (about 0.7 MB), which is kept for the next runs. A check in your browser is feedback for you, not proof that the code is right for every input.
Check yourself
6 questions about this lesson. Every answer and why it is right is on the page, behind “Show the answer”. Your score stays in this browser.
References
- PostgreSQL 18: Sorting Rows (ORDER BY) (The PostgreSQL Global Development Group)
- PostgreSQL 18: LIMIT and OFFSET (The PostgreSQL Global Development Group)
- PostgreSQL 18: SELECT (the LIMIT and FETCH clauses) (The PostgreSQL Global Development Group)
- PostgreSQL 18: Collation Support (The PostgreSQL Global Development Group)
- The SELECT statement (ORDER BY and LIMIT) (SQLite)
- Datatypes In SQLite (sort order and collating sequences) (SQLite)
- SQLite Release History (SQLite)
- MySQL 8.4: Sorting Rows (Oracle Corporation)
- MySQL 8.4: SELECT Statement (ORDER BY and LIMIT) (Oracle Corporation)
- MySQL 8.4: Working with NULL Values (Oracle Corporation)
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress