Your country

Tools that support it use your country for local currency, number formats, units and paper size. Your choice is saved only in this browser.

Type a name or a two-letter code. Use the up and down arrow keys to move through the countries, Enter to choose one and Escape to close.

SQL (PostgreSQL, MySQL, SQLite) Module 2 – Filtering and sorting rows

DISTINCT and CASE expressions

Remove duplicate rows with SQL DISTINCT, label values with searched and simple CASE expressions, and keep one row per key with PostgreSQL DISTINCT ON.

  • Beginner
  • 20 minutes
  • Examples run with sql.js 1.14.2
  • By MySmartCoPilot

What you will learn

  • Remove duplicate rows with DISTINCT
  • Derive categories with searched and simple CASE expressions
  • Select one row per key with DISTINCT ON in PostgreSQL

Before you start

On this page

The last lesson of this module adds two tools that shape what a query returns. DISTINCT removes repeated rows, so “which cities do our customers live in?” lists each city once. CASE computes a value from a set of conditions, so a price can become a label such as “budget” or “premium”, and a status can get a custom sort order.

DISTINCT removes repeated rows

Put DISTINCT right after SELECT, and rows that are identical in every column of the result appear once:

Cities, and cities with their states SQL · distinct_cities.sql
CREATE TABLE customers (id INTEGER PRIMARY KEY, name TEXT NOT NULL, city TEXT, state TEXT);
INSERT INTO customers VALUES
  (1, 'Asha',   'Pune',       'Maharashtra'),
  (2, 'Bilal',  'Hyderabad',  'Telangana'),
  (3, 'Chitra', 'Pune',       'Maharashtra'),
  (4, 'Dev',    'Bilaspur',   'Chhattisgarh'),
  (5, 'Esha',   'Bilaspur',   'Himachal Pradesh'),
  (6, 'Farhan', NULL,         NULL),        -- address not given
  (7, 'Gita',   NULL,         NULL),
  (8, 'Hari',   'Hyderabad',  'Telangana');

-- One row per city. The empty first row is NULL: two customers have
-- no city, and DISTINCT counts their NULLs as one value.
SELECT DISTINCT city FROM customers ORDER BY city;

-- One row per combination of city and state.
SELECT DISTINCT city, state FROM customers ORDER BY state, city;

Output

┌───────────┐
│   city    │
├───────────┤
│           │
│ Bilaspur  │
│ Hyderabad │
│ Pune      │
└───────────┘
┌───────────┬──────────────────┐
│   city    │      state       │
├───────────┼──────────────────┤
│           │                  │
│ Bilaspur  │ Chhattisgarh     │
│ Bilaspur  │ Himachal Pradesh │
│ Pune      │ Maharashtra      │
│ Hyderabad │ Telangana        │
└───────────┴──────────────────┘

Recorded with sql.js 1.14.2 (SQLite 3.49.1) on macOS 26 arm64. To run it yourself: sqlite3 -box :memory: < distinct_cities.sql

Six of the eight customers live in three named cities, and two have no city at all. The first query returns four rows: the three cities and one NULL, shown as the empty first row (NULL sorts first in SQLite). Although two NULLs are never equal in a comparison, DISTINCT treats them as the same value, and so do PostgreSQL and MySQL.

The second query asks for distinct pairs of city and state, and Bilaspur now appears twice: the data has two different places of that name, one in Chhattisgarh and one in Himachal Pradesh. DISTINCT always applies to the whole row of the result: SELECT DISTINCT city, state cannot be told to compare only the cities. When you want one row per city but other columns too, the question is really “which row of each group?”, which the last section of this lesson answers.

Two habits help. First, when a query returns duplicates you did not expect, find out why before adding DISTINCT: in later modules the cause is often a join that multiplies rows, and DISTINCT only hides it. Second, DISTINCT is not free: to find the repeats the database has to compare the rows with each other, which takes time and memory on a large result.

CASE: a value chosen by conditions

A searched CASE lists conditions and the value to return for each:

CASE WHEN condition1 THEN value1 WHEN condition2 THEN value2 ELSE other END

The database tries the WHEN conditions from the top. The first one that is true gives the result, and the rest are not even looked at. If none is true, the result is the ELSE value, or NULL when there is no ELSE. A CASE is an expression like any other, so it can appear in the SELECT list, in WHERE, or in ORDER BY.

Price bands, and a product without a price SQL · price_bands.sql
CREATE TABLE products (id INTEGER PRIMARY KEY, name TEXT NOT NULL, price_paise INTEGER);
INSERT INTO products VALUES
  (1, 'Curry leaves', 1500), (2, 'Toor dal 1 kg', 16500),
  (3, 'Ghee 1 l', 65000),    (4, 'Kokum 200 g', NULL);   -- not priced yet

-- The WHEN conditions are tried from the top; the first true one wins.
-- A NULL price makes every comparison unknown, so ELSE catches it.
SELECT name, price_paise,
       CASE
         WHEN price_paise < 10000 THEN 'budget'
         WHEN price_paise < 50000 THEN 'mid'
         ELSE 'premium'
       END AS band
FROM products;

-- Fixed: test for NULL first. Without an ELSE, no match gives NULL.
SELECT name,
       CASE
         WHEN price_paise IS NULL THEN 'not priced'
         WHEN price_paise < 10000 THEN 'budget'
         WHEN price_paise < 50000 THEN 'mid'
         ELSE 'premium'
       END AS band,
       quote(CASE WHEN price_paise >= 50000 THEN 'yes' END) AS premium_only
FROM products;

Output

┌───────────────┬─────────────┬─────────┐
│     name      │ price_paise │  band   │
├───────────────┼─────────────┼─────────┤
│ Curry leaves  │ 1500        │ budget  │
│ Toor dal 1 kg │ 16500       │ mid     │
│ Ghee 1 l      │ 65000       │ premium │
│ Kokum 200 g   │             │ premium │
└───────────────┴─────────────┴─────────┘
┌───────────────┬────────────┬──────────────┐
│     name      │    band    │ premium_only │
├───────────────┼────────────┼──────────────┤
│ Curry leaves  │ budget     │ NULL         │
│ Toor dal 1 kg │ mid        │ NULL         │
│ Ghee 1 l      │ premium    │ 'yes'        │
│ Kokum 200 g   │ not priced │ NULL         │
└───────────────┴────────────┴──────────────┘

Recorded with sql.js 1.14.2 (SQLite 3.49.1) on macOS 26 arm64. To run it yourself: sqlite3 -box :memory: < price_bands.sql

The first query has a quiet bug: kokum, which has no price yet, is labelled “premium”. Both comparisons are unknown for a NULL, neither WHEN is true, and ELSE takes everything that is left. The second query tests for NULL first. Its last column shows a CASE without ELSE: every product that is not premium gets NULL.

CASE tries WHEN price_paise IS NULL, then < 10000, then < 50000; the first true one gives its label, otherwise ELSE gives premium.WHEN price_paise IS NULLif true: 'not priced'WHEN price_paise < 10000if true: 'budget'WHEN price_paise < 50000if true: 'mid'ELSE'premium'false or unknownfalse or unknownfalse or unknown

How a searched CASE expression picks its result

Text description of the diagram

The diagram shows the second CASE expression of the price bands example as a chain of four boxes, from top to bottom.

  1. WHEN price_paise IS NULL: if true, the result is 'not priced'.
  2. WHEN price_paise < 10000: if true, the result is 'budget'.
  3. WHEN price_paise < 50000: if true, the result is 'mid'.
  4. ELSE: the result is 'premium'.

The arrow from each box to the next is labelled "false or unknown": a test is reached only when every test above it was false or unknown, so a later WHEN never sees a row that an earlier one has already taken. Without the ELSE box, a row that passes none of the tests gets NULL.

The order of the WHENs matters

Because the first true condition wins, a broad condition placed above a narrow one hides it. This is the most common CASE bug:

Overlapping conditions in the wrong order SQL · case_order.sql
CREATE TABLE orders (id INTEGER PRIMARY KEY, total_paise INTEGER NOT NULL);
INSERT INTO orders VALUES (1, 30000), (2, 80000), (3, 250000);

SELECT id, total_paise,
       -- Wrong: every total above 200000 is also above 50000,
       -- so the first WHEN catches it and 'large' is never reached.
       CASE
         WHEN total_paise > 50000  THEN 'medium'
         WHEN total_paise > 200000 THEN 'large'
         ELSE 'small'
       END AS wrong_order,
       -- Right: the narrowest condition first.
       CASE
         WHEN total_paise > 200000 THEN 'large'
         WHEN total_paise > 50000  THEN 'medium'
         ELSE 'small'
       END AS right_order
FROM orders;

Output

┌────┬─────────────┬─────────────┬─────────────┐
│ id │ total_paise │ wrong_order │ right_order │
├────┼─────────────┼─────────────┼─────────────┤
│ 1  │ 30000       │ small       │ small       │
│ 2  │ 80000       │ medium      │ medium      │
│ 3  │ 250000      │ medium      │ large       │
└────┴─────────────┴─────────────┴─────────────┘

Recorded with sql.js 1.14.2 (SQLite 3.49.1) on macOS 26 arm64. To run it yourself: sqlite3 -box :memory: < case_order.sql

The order of 250000 paise is above 200000, but it is also above 50000, and in the first CASE that test comes first, so “large” is never reached. Either put the narrowest condition first, as in the second CASE, or use bands that cannot overlap, such as < 50000, then < 200000, then ELSE, as in the price bands above.

The simple form, and its trap

When every condition compares the same expression with a fixed value, the simple CASE is shorter: CASE status WHEN 'paid' THEN … WHEN 'shipped' THEN … END. It compares with =, and that has a consequence you know from the NULL lesson:

Simple CASE, searched CASE and iif() SQL · simple_case.sql
CREATE TABLE orders (id INTEGER PRIMARY KEY, status TEXT);
INSERT INTO orders VALUES (1, 'paid'), (2, 'shipped'), (3, NULL), (4, 'cancelled');

SELECT id,
       -- The simple form compares status with each value using =.
       CASE status
         WHEN 'paid'    THEN 'in progress'
         WHEN 'shipped' THEN 'in progress'
         WHEN NULL      THEN 'no status'   -- never matches: NULL = NULL is unknown
         ELSE 'closed'
       END AS simple_form,
       -- The searched form can test for NULL.
       CASE
         WHEN status IS NULL THEN 'no status'
         WHEN status IN ('paid', 'shipped') THEN 'in progress'
         ELSE 'closed'
       END AS searched_form,
       -- SQLite's shorthand for a two-way CASE.
       iif(status = 'cancelled', 'refund due', '-') AS with_iif
FROM orders;

Output

┌────┬─────────────┬───────────────┬────────────┐
│ id │ simple_form │ searched_form │  with_iif  │
├────┼─────────────┼───────────────┼────────────┤
│ 1  │ in progress │ in progress   │ -          │
│ 2  │ in progress │ in progress   │ -          │
│ 3  │ closed      │ no status     │ -          │
│ 4  │ closed      │ closed        │ refund due │
└────┴─────────────┴───────────────┴────────────┘

Recorded with sql.js 1.14.2 (SQLite 3.49.1) on macOS 26 arm64. To run it yourself: sqlite3 -box :memory: < simple_case.sql

Order 3 has no status, yet WHEN NULL did not match it: the simple form asks whether status = NULL, which is unknown, so the row fell through to ELSE. The searched form can say WHEN status IS NULL, and it can also use IN to give two statuses the same label. The last column uses iif(), SQLite’s shorthand for a two-way CASE. MySQL has IF(condition, then, else) for the same purpose, and PostgreSQL has neither, so a CASE is the version that runs everywhere.

One more portability rule: PostgreSQL requires the values a CASE can return to be convertible to a single data type, so CASE WHEN … THEN 1 ELSE 'none' END is an error there, while MySQL and SQLite accept it.

CASE in ORDER BY

Sorting by a CASE gives any order you need, such as the order in which an order moves through the shop:

A custom sort order for statuses SQL · case_in_order_by.sql
CREATE TABLE orders (id INTEGER PRIMARY KEY, status TEXT NOT NULL);
INSERT INTO orders VALUES
  (1, 'shipped'), (2, 'pending'), (3, 'delivered'), (4, 'paid'), (5, 'pending'), (6, 'shipped');

-- Sort by the order in which an order moves through the shop, not alphabetically.
SELECT id, status
FROM orders
ORDER BY CASE status
           WHEN 'pending'   THEN 1
           WHEN 'paid'      THEN 2
           WHEN 'shipped'   THEN 3
           WHEN 'delivered' THEN 4
         END,
         id;

Output

┌────┬───────────┐
│ id │  status   │
├────┼───────────┤
│ 2  │ pending   │
│ 5  │ pending   │
│ 4  │ paid      │
│ 1  │ shipped   │
│ 6  │ shipped   │
│ 3  │ delivered │
└────┴───────────┘

Recorded with sql.js 1.14.2 (SQLite 3.49.1) on macOS 26 arm64. To run it yourself: sqlite3 -box :memory: < case_in_order_by.sql

Alphabetical order would put “delivered” first; the CASE turns each status into its position in the process. id is the tie-breaker, as the previous lesson recommends.

Version note

SQLite added iif() in version 3.32.0 with exactly three arguments. Version 3.48.0 allowed the two-argument form and the alternative spelling if(), and 3.49.0 allowed more pairs of conditions and values. The browser runner uses SQLite 3.49.1, so all of them work here, but an older SQLite rejects the newer forms; CASE works in every version.

One row per key: DISTINCT ON in PostgreSQL

A frequent question is “the latest order of each customer”. DISTINCT cannot answer it, because it compares whole rows. PostgreSQL has its own extension of the standard for exactly this: SELECT DISTINCT ON (customer) … keeps the first row of each group of rows that share the expressions in brackets, where “first” is decided by ORDER BY. The ORDER BY must start with the same expressions as DISTINCT ON, and the keys after them choose the row that wins, such as placed_at DESC for the newest. Without an ORDER BY, PostgreSQL’s manual warns, which row is first is unpredictable.

DISTINCT ON is not available in SQLite or MySQL. Here is what SQLite says to it:

PostgreSQL's DISTINCT ON in SQLite SQL · distinct_on.sql
CREATE TABLE orders (id INTEGER PRIMARY KEY, customer TEXT NOT NULL, placed_at TEXT NOT NULL);
INSERT INTO orders VALUES
  (1, 'Asha', '2026-03-02 10:00'), (2, 'Bilal', '2026-03-03 12:30'),
  (3, 'Asha', '2026-03-09 19:15'), (4, 'Bilal', '2026-03-01 08:45');

-- PostgreSQL's DISTINCT ON: the first row of each customer in the ORDER BY.
-- SQLite (like MySQL) does not have it.
SELECT DISTINCT ON (customer) customer, id, placed_at
FROM orders
ORDER BY customer, placed_at DESC;

Output (exit status 1)

Printed as an error (standard error)

Parse error near line 8: near "ON": syntax error

Recorded with sql.js 1.14.2 (SQLite 3.49.1) on macOS 26 arm64. To run it yourself: sqlite3 -box :memory: < distinct_on.sql

In PostgreSQL, the same query returns one row per customer: order 3 for Asha and order 2 for Bilal, their latest. To get that result in every engine, number the rows of each customer with the window function row_number() and keep the first:

The latest order per customer, in portable SQL SQL · latest_per_customer.sql
CREATE TABLE orders (id INTEGER PRIMARY KEY, customer TEXT NOT NULL, placed_at TEXT NOT NULL);
INSERT INTO orders VALUES
  (1, 'Asha', '2026-03-02 10:00'), (2, 'Bilal', '2026-03-03 12:30'),
  (3, 'Asha', '2026-03-09 19:15'), (4, 'Bilal', '2026-03-01 08:45');

-- The latest order of each customer, in a form that PostgreSQL, MySQL 8
-- and SQLite all accept: number each customer's orders, newest first,
-- and keep number 1. (Window functions have a module of their own.)
SELECT customer, id, placed_at
FROM (
  SELECT customer, id, placed_at,
         row_number() OVER (PARTITION BY customer ORDER BY placed_at DESC, id DESC) AS newest
  FROM orders
) AS numbered
WHERE newest = 1
ORDER BY customer;

Output

┌──────────┬────┬──────────────────┐
│ customer │ id │    placed_at     │
├──────────┼────┼──────────────────┤
│ Asha     │ 3  │ 2026-03-09 19:15 │
│ Bilal    │ 2  │ 2026-03-03 12:30 │
└──────────┴────┴──────────────────┘

Recorded with sql.js 1.14.2 (SQLite 3.49.1) on macOS 26 arm64. To run it yourself: sqlite3 -box :memory: < latest_per_customer.sql

PARTITION BY customer restarts the numbering for each customer, and ORDER BY placed_at DESC, id DESC gives number 1 to the newest order, with id deciding if two orders share a time. This form works in PostgreSQL, in MySQL 8.4 and in SQLite 3.25 and newer. MySQL insists on a name for a subquery in FROM, which is why the query calls it numbered. Window functions, including this “top-N per group” pattern, get a module of their own.

SQLite Online (SQL Playground) Run the CASE examples on your own price list and change the bands.

Key takeaways

  • DISTINCT removes rows that are equal in every selected column and treats all NULLs as one value.
  • A searched CASE tries its WHEN conditions from the top and returns the first match; without ELSE, no match gives NULL.
  • Put the narrowest condition first, and test IS NULL before comparisons, or ELSE will catch missing values.
  • The simple CASE x WHEN … compares with =, so WHEN NULL never matches.
  • DISTINCT ON (PostgreSQL only) keeps the first row of each group by ORDER BY; row_number() does the same in every engine.

Exercise

Exercise · Easy · SQL

Label orders as small, medium or large

The table orders has the columns id and total_paise, the order's total in paise (100 paise make one rupee). Some old orders have no total: total_paise is NULL for them.

Write a query that returns each order's id and a column size with one of these labels:

  • unknown when the total is missing;
  • small when the total is below 50000 paise (500 rupees);
  • medium from 50000 paise up to, but not including, 200000 paise;
  • large from 200000 paise (2,000 rupees) up.

A total of exactly 50000 is medium and one of exactly 200000 is large. The order of the rows does not matter. The sample tests include missing totals and totals right on the boundaries.

Starter code · order_sizes.sql

-- Label each order: 'unknown' without a total, 'small' below 50000 paise,
-- 'medium' below 200000 paise, 'large' from 200000 paise up.
SELECT id,
       'small' AS size
FROM orders;
The sample tests · tests.yaml
# Sample tests: each one builds a small orders table, runs your query on it and compares the rows it returns
# (in any order).
tests:
  - name: labels each order by its total
    setup: |
      CREATE TABLE orders (id INTEGER PRIMARY KEY, total_paise INTEGER);
      INSERT INTO orders VALUES (1, 12000), (2, 75000), (3, 250000), (4, NULL), (5, 199999);
    expected:
      columns: [id, size]
      rows:
        - [1, small]
        - [2, medium]
        - [3, large]
        - [4, unknown]
        - [5, medium]
  - name: puts a total exactly on a boundary in the higher band
    setup: |
      CREATE TABLE orders (id INTEGER PRIMARY KEY, total_paise INTEGER);
      INSERT INTO orders VALUES (1, 49999), (2, 50000), (3, 200000), (4, 0);
    expected:
      columns: [id, size]
      rows:
        - [1, small]
        - [2, medium]
        - [3, large]
        - [4, small]
  - name: labels missing totals as unknown, not large
    setup: |
      CREATE TABLE orders (id INTEGER PRIMARY KEY, total_paise INTEGER);
      INSERT INTO orders VALUES (1, NULL), (2, 1500000), (3, NULL);
    expected:
      columns: [id, size]
      rows:
        - [1, unknown]
        - [2, large]
        - [3, unknown]
A hint

Use a searched CASE with one WHEN per label, tried from the top: put WHEN total_paise IS NULL first, because a comparison with a missing total is unknown and would otherwise fall through to ELSE. Then test the bands from the smallest up with <, and let ELSE give large. Name the column with AS size.

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.

  1. Question 1 of 6 What does SELECT DISTINCT city, state FROM customers remove?

    Choose one answer.

    Show the answer to question 1

    Answer: Rows that have the same city and the same state as another row

    DISTINCT compares whole rows of the result: two rows are repeats only when every selected column matches. Two places with the same name in different states stay as two rows, and rows with NULLs are kept, with all NULLs treated as one value.

  2. Question 2 of 6 What label does this CASE expression return?

    Read the code, then choose one answer.

    SELECT CASE
             WHEN 250000 > 50000  THEN 'medium'
             WHEN 250000 > 200000 THEN 'large'
             ELSE 'small'
           END AS size;
    Show the answer to question 2

    Answer: medium

    A CASE returns the value of its first true WHEN. 250000 is above 50000, so the first WHEN already gives medium and the test for large is never reached. Putting the narrowest condition first, > 200000, would give large.

  3. Question 3 of 6 A CASE expression has no ELSE, and none of its WHEN conditions is true for a row. What does it return for that row?

    Choose one answer.

    Show the answer to question 3

    Answer: NULL

    ELSE is optional; without it, a row that matches no WHEN gets NULL in PostgreSQL, MySQL and SQLite alike. Add an ELSE when every row needs a label.

  4. Question 4 of 6 What does this query return?

    Read the code, then choose one answer.

    SELECT CASE NULL WHEN NULL THEN 'match' ELSE 'no match' END;
    Show the answer to question 4

    Answer: no match

    The simple form compares its expression with each WHEN value using =, and NULL = NULL is unknown, not true. No WHEN matches, so the ELSE value is returned. Test for missing values with a searched CASE: WHEN x IS NULL.

  5. Question 5 of 6 Which statement about DISTINCT ON is true?

    Choose one answer.

    Show the answer to question 5

    Answer: It is a PostgreSQL extension, and its ORDER BY must start with the DISTINCT ON expressions

    DISTINCT ON (key) keeps the first row of each group of rows with the same key, in the order that ORDER BY gives, and that ORDER BY must begin with the same key. SQLite and MySQL reject it; row_number() in a subquery gives the same result in all three engines.

  6. Question 6 of 6 The column city holds the five values Pune, NULL, Pune, NULL and Agra. How many rows does SELECT DISTINCT city return?

    Type a number.

    Show the answer to question 6

    Answer: 3

    Pune and Agra appear once each, and the two NULLs count as one value for DISTINCT, so the result has three rows: Pune, Agra and one NULL.

References

Related tools

Report a problem with this lesson

Quick answers and tool search

Type to search tools or to get a quick answer, for example 18% of 2500. Use the up and down arrow keys to move through the results, Enter to choose, and Escape to close.