GreenStore's tables have no duplicate rows: each one has its id primary key, so no two are alike. And yet, as soon as you project only part of the columns, repetitions start appearing everywhere. Asking "which countries do I have customers in?" over a fifteen-row table returns fifteen answers when there are only three distinct countries. In this lesson you'll learn why that happens, how DISTINCT solves it, why it acts on the complete combination of the SELECT's columns (the most common misunderstanding of the module), where it fits in the logical execution order, and when the appearance of DISTINCT in a query is really a sign that the query is badly framed.
Contents
- Why repeated rows appear
DISTINCTover one columnDISTINCTover several columns: the classic misunderstanding- Where
DISTINCTfits in the logical order DISTINCTcombined withWHEREand withORDER BYCOUNT(DISTINCT column): a preview of module 4DISTINCT ON: PostgreSQL's extension- The cost of
DISTINCTand when it gives away a mistake - Common Mistakes and Tips
- Exercises
- Conclusion
- Why repeated rows appear
Let's start with the problem:
| country |
|---|
| Spain |
| Spain |
| Spain |
| Spain |
| Spain |
| Spain |
| Portugal |
| Portugal |
| France |
| France |
| Spain |
| Spain |
| Spain |
| Spain |
| Spain |
15 rows. One per customer, because SELECT returns one result row for each source row. The projection has removed the columns that made each row unique (id, email, name...), and what's left repeats.
It's important to understand that this isn't a fault in the data. In the pure relational model a relation is a set and admits no duplicates, but SQL doesn't work with sets: it works with multisets (bags): it allows repetitions and only removes them if you ask explicitly. It was a pragmatic decision by the language's designers: removing duplicates costs time, and most queries don't need it.
The same with the cities:
It returns 15 rows with Valencia four times and Barcelona twice.
DISTINCT over one column
DISTINCT over one columnThe DISTINCT keyword is written immediately after SELECT and removes repeated rows from the result:
| country |
|---|
| Spain |
| Portugal |
| France |
3 rows. Now you've answered the question "which countries do I have customers in?".
| city |
|---|
| Valencia |
| Castellón |
| Madrid |
| Barcelona |
| Lisbon |
| Porto |
| Lyon |
| Paris |
| Alicante |
| Seville |
| Zaragoza |
11 rows out of the 15: Valencia appeared 4 times and Barcelona 2.
Another useful case: finding out which payment methods are actually used. The table's CHECK constraint allows four, but are they all used?
| payment_method |
|---|
| card |
| transfer |
| paypal |
| cash_on_delivery |
All four. And the statuses:
| status |
|---|
| delivered |
| shipped |
| paid |
| pending |
| cancelled |
All five values of the domain are represented.
Very important:
DISTINCTdoesn't sort. The results above come out in an order PostgreSQL picks depending on how it removed the duplicates (usually with a hash table, whose order is unpredictable). You could perfectly well seeFrance, Spain, Portugal. If you want a particular order,ORDER BY(section 5).
Two syntax details:
DISTINCTaffects the whole column list, not the one next to it.SELECT DISTINCT country, cityisn't "the distinct country and the city": that's section 3.DISTINCT(country)works, but it's misleading: those parentheses belong to an expression, not to a function.SELECT DISTINCT (country), citystill appliesDISTINCTto both columns. Don't writeDISTINCTwith parentheses; it misleads whoever reads it.
And a note about nulls: for DISTINCT, all NULLs count as one and the same value and collapse into a single row. It's a deliberate exception to the rule that NULL isn't equal to NULL, and you'll see it in exercise 3.
DISTINCT over several columns: the classic misunderstanding
DISTINCT over several columns: the classic misunderstandingThis is the part to internalise properly:
| country | city |
|---|---|
| Spain | Valencia |
| Spain | Castellón |
| Spain | Madrid |
| Spain | Barcelona |
| Spain | Alicante |
| Spain | Seville |
| Spain | Zaragoza |
| Portugal | Lisbon |
| Portugal | Porto |
| France | Lyon |
| France | Paris |
11 rows, and Spain appears seven times. Didn't we say DISTINCT removes duplicates?
It has removed them. What's going on is that DISTINCT compares the complete row, not column by column. (Spain, Valencia) and (Spain, Madrid) are two different rows, even though they share the first value. A row is only removed if all of its values match another's: (Spain, Valencia) appeared four times in the table and one has been left.
| What many people think it does | What it really does |
|---|---|
"One distinct value of country, and for each one the city" |
Each distinct combination of (country, city) |
| It should return 3 rows | It returns 11 |
Think of it this way: DISTINCT looks at the result row as if it were a tuple and asks itself "have I already seen exactly this tuple?".
Another example, this time on the catalogue: which combinations of category and supplier exist?
With ORDER BY so we can read it (we justify it in section 5):
| category_id | supplier_id |
|---|---|
| 1 | 1 |
| 1 | 2 |
| 2 | 3 |
| 2 | 4 |
| 3 | 3 |
| 3 | 5 |
| 4 | 1 |
| 4 | 2 |
| 4 | 3 |
| 5 | 4 |
| 5 | 5 |
| 6 | 5 |
12 combinations out of 20 products. There's a lot of business information to read in that table: category 4 (Drinks) is supplied by three different suppliers, whereas category 6 (Supplements) depends on a single one, and that single supplier —number 5, EcoNordic— is precisely the inactive one. Without DISTINCT you'd have had to read 20 rows to reach the same conclusion.
If what you really want is "one country per row and something about its cities", DISTINCT isn't the tool: you need to group, which is GROUP BY (lesson 04-05). The conceptual difference, to be clear about from now:
| Tool | What it does | Lesson |
|---|---|---|
DISTINCT |
Removes repeated rows from the result as it stands | 02-04 |
GROUP BY |
Collapses groups of rows into one and lets you compute over each group (count, sum, average) | 04-05 |
- Where
DISTINCT fits in the logical order
DISTINCT fits in the logical orderDISTINCT is applied after the SELECT's projection and before ORDER BY. That's consistent: you can only compare duplicate rows once you know which columns they contain.
flowchart LR
A["1 · FROM<br/>customers<br/>15 rows"] --> B["2 · WHERE<br/>filters rows"]
B --> C["3 · SELECT<br/>projects country, city"]
C --> D["3b · DISTINCT<br/>removes duplicates<br/>11 rows"]
D --> E["4 · ORDER BY"]
E --> F["5 · LIMIT"]
Two rules follow from that, which you'll see in action right away:
| Rule | Consequence |
|---|---|
WHERE runs before DISTINCT |
First you filter, then you deduplicate. Never the other way round |
ORDER BY runs after DISTINCT |
You can only sort by columns that survived the projection |
DISTINCT combined with WHERE and with ORDER BY
DISTINCT combined with WHERE and with ORDER BY5.1. With WHERE
| city |
|---|
| Valencia |
| Castellón |
| Madrid |
| Barcelona |
| Alicante |
| Seville |
| Zaragoza |
7 Spanish cities. The order is: FROM brings the 15 rows → WHERE leaves 11 → SELECT projects city → DISTINCT reduces to 7.
5.2. With ORDER BY
| country |
|---|
| France |
| Portugal |
| Spain |
Now the order is guaranteed (alphabetical). ORDER BY is the next lesson; here we use it only to make the results readable.
5.3. The restriction: you can only sort by what you've selected
Try this:
ERROR: for SELECT DISTINCT, ORDER BY expressions must appear in select list
LINE 3: ORDER BY city;
^The message is explicit and the reason is logical, not arbitrary. After the DISTINCT only three rows remain: Spain, Portugal and France. The Spain row comes from eleven original rows with eleven different cities. Which of the eleven should it be sorted by? The question has no answer, so PostgreSQL refuses to invent one.
The rule, worth memorising: with SELECT DISTINCT, everything that appears in ORDER BY must also appear in the SELECT list.
If what you wanted was to sort the countries by some criterion derived from the cities, you need GROUP BY and an aggregate function (module 4).
A frequent case that also fails for this reason: sorting by a column you don't project.
The same message. And if you "fix" it by adding price to the SELECT, you change the question: you'd go from "distinct categories" (6 rows) to "distinct combinations of category and price" (20 rows, because almost every price is unique). Adding columns to the SELECT weakens the DISTINCT.
COUNT(DISTINCT column): a preview of module 4
COUNT(DISTINCT column): a preview of module 4Often you don't want the list of distinct values, but how many there are. For that you combine DISTINCT with the COUNT aggregate function:
SELECT COUNT(*) AS row_count,
COUNT(city) AS non_null_cities,
COUNT(DISTINCT city) AS distinct_cities,
COUNT(DISTINCT country) AS distinct_countries
FROM customers;| row_count | non_null_cities | distinct_cities | distinct_countries |
|---|---|---|---|
| 15 | 15 | 11 | 3 |
Notice the difference between the three variants of COUNT:
| Expression | What it counts |
|---|---|
COUNT(*) |
Rows, regardless of values |
COUNT(column) |
Non-null values of that column |
COUNT(DISTINCT column) |
Distinct and non-null values |
Here COUNT(city) matches COUNT(*) because in GreenStore no customer has a null city, but the column allows nulls and in another database the difference would be visible.
We won't go deeper: COUNT, SUM, AVG, MIN and MAX are lesson 04-04, and grouping by category is 04-05. We leave it here simply because it's the most frequent application of DISTINCT in real analysis work.
DISTINCT ON: PostgreSQL's extension
DISTINCT ON: PostgreSQL's extensionPostgreSQL adds a variant that isn't in the SQL standard and that is enormously useful: DISTINCT ON (columns) keeps the first row of each group of values, according to the order you specify.
Problem: the most expensive product in each category.
SELECT DISTINCT ON (category_id)
category_id,
id,
name,
price
FROM products
ORDER BY category_id, price DESC;| category_id | id | name | price |
|---|---|---|---|
| 1 | 1 | Extra virgin olive oil 500 ml | 12.50 |
| 2 | 6 | Aloe vera face cream 50 ml | 18.90 |
| 3 | 13 | Soy wax candles (pack of 2) | 13.75 |
| 4 | 15 | Ceremonial matcha green tea 30 g | 22.00 |
| 5 | 19 | Natural stick deodorant 50 g | 7.80 |
| 6 | 20 | Spirulina capsules 120 units | 16.40 |
Six rows, one per category, with the most expensive product in each. This is impossible with plain DISTINCT.
How it works, step by step:
ORDER BY category_id, price DESCsorts every row: first grouped by category, and inside each category, from most to least expensive.DISTINCT ON (category_id)walks that sorted result and keeps the first row for each value ofcategory_id, discarding the rest.
From which comes the golden rule:
The columns of
DISTINCT ON (...)must be the first ones in theORDER BY, and in the same order. If they aren't, PostgreSQL raises an error; and if theORDER BYdoesn't break ties properly after that, the row picked within each group is unpredictable.
If you change price DESC to price, you get the cheapest product in each category:
SELECT DISTINCT ON (category_id)
category_id, id, name, price
FROM products
ORDER BY category_id, price;| category_id | id | name | price |
|---|---|---|---|
| 1 | 5 | Organic crushed tomato 400 g | 1.95 |
| 2 | 9 | Calendula lip balm 15 ml | 4.60 |
| 3 | 11 | Loofah scrubber (pack of 3) | 5.50 |
| 4 | 14 | Organic chamomile tea 20 bags | 3.25 |
| 5 | 18 | Bamboo toothbrush | 3.50 |
| 6 | 20 | Spirulina capsules 120 units | 16.40 |
The "the most X row of each group" pattern (each customer's last order, each product's most recent review, the highest price in each category) is one of the most requested in the real world, and DISTINCT ON solves it in three lines.
Portability:
| Engine | How it's done |
|---|---|
| PostgreSQL | DISTINCT ON (...) with the right ORDER BY |
| SQL standard / MySQL 8 / SQL Server / Oracle | Window function: ROW_NUMBER() OVER (PARTITION BY category_id ORDER BY price DESC) and keeping row 1 |
| SQLite | Window function (since 3.25) or the MAX() with GROUP BY trick |
| MySQL 5.7 | Correlated subquery (module 7) |
The portable form with window functions is studied in lesson 10-03, and it's the one you'll have to use if your SQL has to work outside PostgreSQL. While you're working with PostgreSQL, DISTINCT ON is shorter and usually faster.
- The cost of
DISTINCT and when it gives away a mistake
DISTINCT and when it gives away a mistakeDISTINCT isn't free. To know whether a row is repeated, PostgreSQL has to compare every row against the others, and it does so in one of two ways:
| Strategy | How it works | Cost |
|---|---|---|
HashAggregate |
Builds a hash table with the rows already seen | Fast, but consumes memory proportional to the number of distinct values |
Sort + Unique |
Sorts every row and removes adjacent equal ones | Requires sorting the whole set; if it doesn't fit in memory, it spills to disk |
With 15 rows it's instantaneous. With ten million rows and a long text column, an unnecessary DISTINCT can turn a 50 ms query into a 30-second one. You'll be able to see it yourself with EXPLAIN in module 8.
And there's something more important than the cost: an unexpected DISTINCT is usually the symptom of a badly framed query, not the solution. The typical case arrives in module 3:
-- Preview of module 3: "customers who have placed an order"
SELECT c.name, c.last_name
FROM customers AS c
JOIN orders AS o ON o.customer_id = c.id;That query returns 20 rows and not 12, because Lucía Martínez has three orders (1, 5 and 15) and appears three times. The natural reflex is to add DISTINCT and relax. But the DISTINCT fixes nothing: it hides the fact that the join has multiplied the rows. If tomorrow you add a column to the SELECT —say o.order_date—, the rows duplicate again and the DISTINCT stops helping.
The checklist for when you catch yourself writing DISTINCT:
- Are there really duplicates, or is my query creating them? If it's the latter, the problem is in the join, not in the projection.
- Do I want "distinct values" or "one result per group"? If it's the latter, it's
GROUP BY(04-05) orDISTINCT ON(section 7). - Do I want to check existence? Then the right tool is
EXISTS(lesson 07-03), which doesn't multiply rows and therefore needs no deduplication. - Am I deduplicating by columns I don't need? Removing an unnecessary column from the
SELECTcan make theDISTINCTredundant.
DISTINCT is perfectly legitimate for what we've done in this lesson: asking "what distinct values are there in this column?". What's suspicious is using it as a patch.
Common Mistakes and Tips
- Believing that
DISTINCT column1, column2deduplicates only by the first one. It deduplicates by the complete combination. It's misunderstanding number one. - Writing
DISTINCT(column). It works, but it makes people thinkDISTINCTis a function applied to that column. It isn't. WriteDISTINCT column1, column2. - Expecting
DISTINCTto sort. It doesn't sort. If you need order,ORDER BY. - Sorting by a column that isn't in a
SELECT DISTINCT.ORDER BY expressions must appear in select list. And "fixing it" by adding the column changes the question. - Adding columns and not noticing that the
DISTINCTstops helping. Each new column can multiply the result's rows. - Using
DISTINCTto cover up duplicates created by aJOIN. Fix the query, not the symptom. - Forgetting that
NULLs are grouped.DISTINCTreturns a single row withNULL, even if there are a hundred. - Using
DISTINCT ONwithout the rightORDER BY. The row kept from each group is then unpredictable: one today, another tomorrow. - Tip: to count unique values,
COUNT(DISTINCT col)instead of fetching the list and counting it by hand. - Tip: use
DISTINCTas an exploration tool.SELECT DISTINCT status FROM orders;is the fastest way to find out what values a column actually holds, including the ones that shouldn't be there. - Tip: if
DISTINCTis slow, look at the plan.EXPLAIN(module 8) will tell you whether it's sorting on disk.
Exercises
Exercise 1
Marketing wants to know which cities purchases have been made from in Portugal and France. Write a query that returns the distinct combinations of country and city for the customers who aren't from Spain, sorted by country and city. Explain why the result has the number of rows it has.
Exercise 2
A colleague writes this query to find out how many distinct payment methods are used and is surprised by the result:
Explain what it really returns, how many rows and why it doesn't answer their question. Then write the two correct queries: the one that gives the list of methods and the one that gives the number.
Exercise 3
Using DISTINCT ON, get the most recent order of each customer who has purchased: customer_id, the order's id, order_date and status. Explain why the result has 12 rows and not 15, and what role the ORDER BY plays.
Solutions
Solution 1
| country | city |
|---|---|
| France | Lyon |
| France | Paris |
| Portugal | Lisbon |
| Portugal | Porto |
4 rows. The reasoning follows the logical execution order:
FROM customers→ 15 rows.WHERE country <> 'Spain'→ 4 remain (customers 7, 8, 9 and 10). There's no null problem here becausecountryisNOT NULL; if it allowed nulls, those customers would have been lost silently, as you saw in 02-03.SELECT country, city→ projects two columns of those 4 rows.DISTINCT→ looks for repeated combinations... and there aren't any, because the four foreign customers live in four different cities.
That is, in this particular case DISTINCT removes nothing. That doesn't make it useless: it guarantees that if a second customer registers in Lisbon tomorrow, the report will still be correct.
Solution 2
Your colleague's query returns the distinct combinations of payment method and status:
| payment_method | status |
|---|---|
| card | delivered |
| card | cancelled |
| card | shipped |
| card | paid |
| transfer | delivered |
| transfer | paid |
| paypal | delivered |
| paypal | shipped |
| cash_on_delivery | delivered |
| cash_on_delivery | pending |
10 rows. card appears four times, once for each status in which there's an order paid by card. It doesn't answer "how many distinct payment methods are there" because DISTINCT deduplicates the complete pair, and the pairs are different even though they share the method.
The two correct queries:
| payment_method |
|---|
| card |
| cash_on_delivery |
| paypal |
| transfer |
| methods_used |
|---|
| 4 |
The moral: every column you add to the SELECT can multiply the result's rows, because it weakens the equality condition DISTINCT uses. Before adding a column to a SELECT DISTINCT, ask yourself whether it still answers your question.
Solution 3
SELECT DISTINCT ON (customer_id)
customer_id,
id,
order_date,
status
FROM orders
ORDER BY customer_id, order_date DESC;| customer_id | id | order_date | status |
|---|---|---|---|
| 1 | 15 | 2025-12-02 | delivered |
| 2 | 11 | 2025-09-09 | delivered |
| 3 | 3 | 2025-04-02 | delivered |
| 4 | 16 | 2025-12-19 | shipped |
| 5 | 18 | 2026-01-27 | paid |
| 6 | 19 | 2026-02-09 | paid |
| 7 | 17 | 2026-01-13 | shipped |
| 8 | 9 | 2025-07-15 | delivered |
| 9 | 20 | 2026-02-21 | pending |
| 10 | 12 | 2025-10-01 | delivered |
| 11 | 13 | 2025-10-22 | delivered |
| 12 | 14 | 2025-11-14 | delivered |
12 rows and not 15. The reason lies in the dataset's deliberate gaps: customers 13, 14 and 15 have never placed an order, so they don't appear in the orders table and there's nothing to group for them. A query over orders can only speak about customers who have ordered; to list the ones who never bought as well you need to combine both tables with a LEFT JOIN, which is exactly the content of lesson 03-03.
The ORDER BY plays a twofold role and both parts are essential:
customer_idfirst because it's theDISTINCT ONcolumn: PostgreSQL requires them to match, since it needs each customer's rows together to keep one.order_date DESCsecond because it determines which of each customer's rows survives: the first of the group, that is, the one with the highest date.
If you wrote ORDER BY customer_id, order_date (ascending), you'd get each customer's first order. And if you left out the second ORDER BY column, the row picked within each customer would be whichever the engine returned first, which can change between runs.
A quick check: Lucía Martínez (customer 1) has orders 1, 5 and 15, with dates 2025-03-04, 2025-05-07 and 2025-12-02. Number 15 came out, the most recent. Correct.
Conclusion
You now know how to handle duplicates:
- Repeated rows don't come from the data, but from the projection: when you remove columns, what's left repeats. SQL works with multisets and doesn't remove them unless you ask.
DISTINCTis written right afterSELECTand removes repeated rows by comparing the complete combination of projected columns, not just the first one.- It's applied after the projection and before
ORDER BY, which is where the restriction that everything you sort by must be in theSELECTcomes from. NULLs collapse into a single row, as an exception to the general rule about nulls.COUNT(DISTINCT column)counts unique values and is the most common application in data analysis; the full aggregations arrive in module 4.DISTINCT ON (...)is a PostgreSQL extension that returns the first row of each group according to theORDER BY, and solves the "the most recent / most expensive row of each X" pattern in three lines. Outside PostgreSQL it's done with window functions (module 10).DISTINCTcosts (sorting or building a hash table) and, when it turns up by surprise, it usually gives away a badly framed query rather than solving a problem.
In the next lesson, Sorting Data with ORDER BY, you'll stop accepting whatever order the engine sees fit to give you. You'll see how to sort by one or several columns, how ties are resolved, how to sort by aliases and by calculated expressions, where PostgreSQL puts null values (and why MySQL puts them at the other end) and why linguistic collation can place a capital letter exactly where you don't expect it.
SQL Course
Module 1: Introduction to SQL
- What is SQL?
- Setting up your SQL environment
- Basic SQL syntax
- Understanding databases and tables
- The relational model: primary and foreign keys
- The course database: GreenStore
Module 2: Basic SQL queries
- The SELECT statement
- Aliases, expressions and calculated columns
- Filtering data with WHERE
- DISTINCT and removing duplicates
- Sorting data with ORDER BY
- Limiting results with LIMIT
Module 3: Working with multiple tables
- JOIN operations
- INNER JOIN
- LEFT JOIN
- RIGHT JOIN
- FULL OUTER JOIN
- SELF JOIN and CROSS JOIN
- Set operations: UNION, INTERSECT and EXCEPT
Module 4: Advanced data filtering
- Using LIKE for pattern matching
- The IN and BETWEEN operators
- NULL values and IS NULL
- Aggregate functions: COUNT, SUM, AVG, MIN and MAX
- Aggregating data with GROUP BY
- The HAVING clause
Module 5: Data manipulation
- Creating tables and constraints with CREATE TABLE
- The INSERT statement
- The UPDATE statement
- The DELETE statement
- The UPSERT (MERGE) statement
- Changing the schema: ALTER TABLE and safe migrations
Module 6: Advanced SQL functions
- String functions
- Numeric functions
- Date and time functions
- Type conversion and handling NULL: CAST and COALESCE
- Conditional expressions
Module 7: Subqueries and nested queries
- Introduction to subqueries
- Correlated subqueries
- EXISTS and NOT EXISTS
- Using subqueries in SELECT, FROM and WHERE
- Subquery or JOIN: which one to choose
Module 8: Indexes and performance tuning
- Understanding indexes
- Creating and managing indexes
- Index types and when not to index
- Query optimization techniques
- Analyzing query performance
Module 9: Transactions and concurrency
- Introduction to transactions
- ACID properties
- Transaction control statements
- Isolation levels and concurrency anomalies
- Handling concurrency: locks and deadlocks
Module 10: Advanced topics
- Views
- Common table expressions (CTEs)
- Window functions
- Stored procedures
- Triggers
- JSON and semi-structured data
Module 11: SQL in practice
- Real-world use cases
- Best practices
- Security: SQL injection, permissions and roles
- SQL for data analysis
- SQL in web development
