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

  1. Why repeated rows appear
  2. DISTINCT over one column
  3. DISTINCT over several columns: the classic misunderstanding
  4. Where DISTINCT fits in the logical order
  5. DISTINCT combined with WHERE and with ORDER BY
  6. COUNT(DISTINCT column): a preview of module 4
  7. DISTINCT ON: PostgreSQL's extension
  8. The cost of DISTINCT and when it gives away a mistake
  9. Common Mistakes and Tips
  10. Exercises
  11. Conclusion

  1. Why repeated rows appear

Let's start with the problem:

SELECT country
FROM customers;
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:

SELECT city
FROM customers;

It returns 15 rows with Valencia four times and Barcelona twice.

  1. DISTINCT over one column

The DISTINCT keyword is written immediately after SELECT and removes repeated rows from the result:

SELECT DISTINCT country
FROM customers;
country
Spain
Portugal
France

3 rows. Now you've answered the question "which countries do I have customers in?".

SELECT DISTINCT city
FROM customers;
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?

SELECT DISTINCT payment_method
FROM orders;
payment_method
card
transfer
paypal
cash_on_delivery

All four. And the statuses:

SELECT DISTINCT status
FROM orders;
status
delivered
shipped
paid
pending
cancelled

All five values of the domain are represented.

Very important: DISTINCT doesn'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 see France, Spain, Portugal. If you want a particular order, ORDER BY (section 5).

Two syntax details:

  1. DISTINCT affects the whole column list, not the one next to it. SELECT DISTINCT country, city isn't "the distinct country and the city": that's section 3.
  2. DISTINCT(country) works, but it's misleading: those parentheses belong to an expression, not to a function. SELECT DISTINCT (country), city still applies DISTINCT to both columns. Don't write DISTINCT with 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.

  1. DISTINCT over several columns: the classic misunderstanding

This is the part to internalise properly:

SELECT DISTINCT country, city
FROM customers;
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?

SELECT DISTINCT category_id, supplier_id
FROM products;

With ORDER BY so we can read it (we justify it in section 5):

SELECT DISTINCT category_id, supplier_id
FROM products
ORDER BY category_id, supplier_id;
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

  1. Where DISTINCT fits in the logical order

DISTINCT 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

  1. DISTINCT combined with WHERE and with ORDER BY

5.1. With WHERE

SELECT DISTINCT city
FROM customers
WHERE country = 'Spain';
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 cityDISTINCT reduces to 7.

5.2. With ORDER BY

SELECT DISTINCT country
FROM customers
ORDER BY country;
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:

SELECT DISTINCT country
FROM customers
ORDER BY city;
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.

-- ⚠️ ERROR
SELECT DISTINCT category_id FROM products ORDER BY price;

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.

  1. COUNT(DISTINCT column): a preview of module 4

Often 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.

  1. DISTINCT ON: PostgreSQL's extension

PostgreSQL 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:

  1. ORDER BY category_id, price DESC sorts every row: first grouped by category, and inside each category, from most to least expensive.
  2. DISTINCT ON (category_id) walks that sorted result and keeps the first row for each value of category_id, discarding the rest.

From which comes the golden rule:

The columns of DISTINCT ON (...) must be the first ones in the ORDER BY, and in the same order. If they aren't, PostgreSQL raises an error; and if the ORDER BY doesn'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.

  1. The cost of DISTINCT and when it gives away a mistake

DISTINCT 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:

  1. 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.
  2. Do I want "distinct values" or "one result per group"? If it's the latter, it's GROUP BY (04-05) or DISTINCT ON (section 7).
  3. 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.
  4. Am I deduplicating by columns I don't need? Removing an unnecessary column from the SELECT can make the DISTINCT redundant.

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, column2 deduplicates 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 think DISTINCT is a function applied to that column. It isn't. Write DISTINCT column1, column2.
  • Expecting DISTINCT to 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 DISTINCT stops helping. Each new column can multiply the result's rows.
  • Using DISTINCT to cover up duplicates created by a JOIN. Fix the query, not the symptom.
  • Forgetting that NULLs are grouped. DISTINCT returns a single row with NULL, even if there are a hundred.
  • Using DISTINCT ON without the right ORDER 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 DISTINCT as 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 DISTINCT is 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:

SELECT DISTINCT payment_method, status
FROM orders;

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

SELECT DISTINCT country, city
FROM customers
WHERE country <> 'Spain'
ORDER BY country, city;
country city
France Lyon
France Paris
Portugal Lisbon
Portugal Porto

4 rows. The reasoning follows the logical execution order:

  1. FROM customers → 15 rows.
  2. WHERE country <> 'Spain' → 4 remain (customers 7, 8, 9 and 10). There's no null problem here because country is NOT NULL; if it allowed nulls, those customers would have been lost silently, as you saw in 02-03.
  3. SELECT country, city → projects two columns of those 4 rows.
  4. 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:

-- The list of methods
SELECT DISTINCT payment_method
FROM orders
ORDER BY payment_method;
payment_method
card
cash_on_delivery
paypal
transfer
-- The number of methods
SELECT COUNT(DISTINCT payment_method) AS methods_used
FROM orders;
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_id first because it's the DISTINCT ON column: PostgreSQL requires them to match, since it needs each customer's rows together to keep one.
  • order_date DESC second 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.
  • DISTINCT is written right after SELECT and 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 the SELECT comes 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 the ORDER 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).
  • DISTINCT costs (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

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