Module 1 · Query fundamentals
Sign in with email or GitHub to spin up your own Postgres sandbox and run the queries for this lesson.
Sign in to start the sandboxNo GitHub? Create an account with email.
Lesson 01 introduced ORDER BY and LIMIT. This lesson goes deeper: stable sorts, dropping duplicates, and the two ways to page through a result set — including why one of them quietly breaks in production.
The seed loaded an articles table — 30 rows, several authors, and a few intentional ties on published_at.
ORDER BY: pick a directionORDER BY col sorts ascending (small to large, oldest first). Add DESC to flip it.
SELECT title, views
FROM articles
ORDER BY views DESC
LIMIT 5;
The top-5 most-viewed posts. Without LIMIT 5, you'd get all 30, sorted.
What happens when two rows have the same sort key? Postgres returns them in some order — but which one is implementation-defined. If the order matters, spell out a tie-breaker.
SELECT title, author, published_at
FROM articles
ORDER BY published_at, id;
published_at has duplicates (Ada's two articles, Grace's first two — see the seed). Adding id as a second sort key makes the result deterministic: same query, same order, every time. For pagination this isn't optional — it's a correctness requirement, as we'll see in a minute.
NULLS FIRST / NULLS LASTPostgres puts NULLs last in ascending sorts and first in descending sorts. Override with NULLS FIRST or NULLS LAST when you want the opposite — most often when you want "newest first, but missing dates at the bottom".
SELECT title, published_at
FROM articles
ORDER BY published_at DESC NULLS LAST;
The seed now includes a couple of NULL published_at values so you can see this directly.
DISTINCT: drop duplicate rowsDISTINCT removes duplicate rows from the result. It's a post-processing step on whatever the SELECT list produced.
SELECT DISTINCT author
FROM articles
ORDER BY author;
Twelve articles came from a handful of repeat authors — DISTINCT collapses them. Note DISTINCT is across all selected columns, not just one: SELECT DISTINCT author, published_at would keep two rows from the same author on different days.
DISTINCT ON (...): one row per group, Postgres-flavoredDISTINCT ON (col) is a Postgres extension: "one row per distinct value of col, and you pick which one with ORDER BY". Handy for "the latest article per author":
SELECT DISTINCT ON (author) author, title, published_at
FROM articles
ORDER BY author, published_at DESC;
The first column(s) in the ORDER BY must match the DISTINCT ON list — that's the rule that lets Postgres pick "the first row per group". Inside each author, published_at DESC chooses the newest.
LIMIT and OFFSET: the obvious way to paginateLIMIT N OFFSET M says "skip M rows, then return N". The classic page-2-of-10 query:
SELECT id, title, published_at
FROM articles
ORDER BY published_at DESC NULLS LAST, id DESC
LIMIT 10 OFFSET 10;
That's page 2 (rows 11–20). Page 3 would be OFFSET 20. Simple, and the right tool for small result sets.
OFFSET trapOFFSET M makes Postgres fetch and discard M rows before returning anything. On page 1 that's free. On page 1000 of a million-row feed, you're scanning a million rows to throw away 999,990 of them.
There's a subtler bug too: if a new row gets inserted between requesting page 1 and page 2, page 2 will repeat a row from page 1 (because everything shifted down by one). The result set isn't stable across requests.
For small admin tables, OFFSET is fine. For user-facing feeds, infinite scroll, or anything that paginates deeply, reach for keyset pagination.
WHEREIdea: instead of "skip 10,000 rows", remember the last row you saw and ask for "rows after that one". With a deterministic ORDER BY, that's just a WHERE clause.
Page 1:
SELECT id, title, published_at
FROM articles
ORDER BY published_at DESC NULLS LAST, id DESC
LIMIT 5;
Note the last row's published_at and id. To get the next page, plug them into a WHERE filter that asks for everything strictly after that key:
SELECT id, title, published_at
FROM articles
WHERE (published_at, id) < ('2024-06-24 08:30:00+00', 26)
ORDER BY published_at DESC NULLS LAST, id DESC
LIMIT 5;
Two important details:
(a, b) < (x, y) does lexicographic ordering — a < x, OR a = x AND b < y. That's exactly the tie-breaker logic we wrote into ORDER BY. They have to match.OFFSET. Each page is a fresh WHERE lookup that an index on (published_at DESC, id DESC) can serve in constant time, no matter how deep you go.The downside: you can't jump to "page 42" — you walk forward one page at a time. For feeds and infinite scroll that's fine; for an admin grid with a page picker, OFFSET is the easier fit.
ORDER BY sorts; add DESC and NULLS FIRST/LAST as needed.DISTINCT drops duplicate rows; DISTINCT ON (col) picks one row per group, chosen by ORDER BY.LIMIT N OFFSET M is the obvious way to paginate — and gets slow and unstable on deep pages.WHERE (key) < (last_seen)) pages in constant time and survives concurrent inserts.Up next: collapsing rows into summaries with aggregations and GROUP BY.