PostgreSQL DISTINCT ON: One Row per Group, With Examples

PostgreSQL DISTINCT ON: One Row per Group, With Examples
In this article
  1. How DISTINCT ON works
  2. Sample setup
  3. DISTINCT vs DISTINCT ON
  4. Example: latest order for each customer
  5. Choosing the winner by a different column
  6. DISTINCT ON multiple columns and expressions
  7. Sorting the final result differently
  8. DISTINCT ON vs row_number()
  9. Performance: making DISTINCT ON fast
  10. Common mistakes
  11. Frequently asked questions
  12. Summary

SELECT DISTINCT ON (column) returns exactly one row for each distinct value of the column you list, and the ORDER BY clause decides which row that is. It is the shortest way in PostgreSQL to answer questions like "the latest order for each customer" or "the most expensive product in each category" while still getting every column of that row.

The rule that trips most people up: the DISTINCT ON expressions must be the first expressions in ORDER BY, and the columns after them pick the winner. This tutorial shows the syntax on a small orders table, what happens with ties and NULLs, how to get more than one row per group, and how DISTINCT ON compares with row_number() and a LATERAL join when the table is large. Every query was run and the output is shown.

How DISTINCT ON works

Plain SELECT DISTINCT removes rows that are identical in every selected column. DISTINCT ON (expression, ...) is looser: two rows count as duplicates when only the listed expressions are equal, even if the other columns differ. From each set of duplicates PostgreSQL keeps the first row and throws the rest away.

"First" means first in the sort order. PostgreSQL sorts the rows (or reads them in order from an index), then walks through them and keeps a row only when the DISTINCT ON value changes. That is why the documentation warns that the first row of each set is unpredictable unless ORDER BY puts the row you want at the top of its group.

So a DISTINCT ON query has two parts that work together:

  • DISTINCT ON (customer_id): defines the groups, one output row per customer.
  • ORDER BY customer_id, order_date DESC: the leading customer_id must match the DISTINCT ON list; order_date DESC ranks rows inside each group so the newest order comes first and wins.

DISTINCT ON is a PostgreSQL extension; it is not in the SQL standard. Oracle, SQL Server and MySQL do not have it, so if you are coming from Oracle, think of it as a short form of the ROW_NUMBER() OVER (PARTITION BY ...) pattern you already know.

Sample setup

Create a small orders table. It has two customers with ties or NULLs on purpose, because those are the cases where DISTINCT ON surprises people: customer 103 placed two orders on the same day, and customer 104 has one order with no date.

CREATE TABLE orders (
    order_id     integer PRIMARY KEY,
    customer_id  integer NOT NULL,
    order_date   date,
    amount       numeric(10,2) NOT NULL,
    status       text NOT NULL
);

INSERT INTO orders (order_id, customer_id, order_date, amount, status) VALUES
    (1, 101, '2026-09-02', 1200.00, 'delivered'),
    (2, 101, '2026-09-20',  450.00, 'delivered'),
    (3, 101, '2026-10-05',  980.00, 'shipped'),
    (4, 102, '2026-08-14', 3100.00, 'delivered'),
    (5, 102, '2026-10-01',  750.00, 'cancelled'),
    (6, 103, '2026-09-11',  300.00, 'delivered'),
    (7, 103, '2026-09-11',  820.00, 'delivered'),
    (8, 104, NULL,          640.00, 'pending'),
    (9, 104, '2026-07-30',  210.00, 'delivered');
Sample orders table

DISTINCT vs DISTINCT ON

Start with plain DISTINCT to see the difference. It can only return the columns that define uniqueness:

SELECT DISTINCT customer_id
  FROM orders
 ORDER BY customer_id;
customer_id
101
102
103
104

As soon as you add order_id or amount to a plain DISTINCT, every row becomes unique again and you get all nine back. A GROUP BY has the same limit: you can get the latest date per customer, but not the other columns of that row.

SELECT customer_id, max(order_date) AS last_order_date, order_id
  FROM orders
 GROUP BY customer_id;
ERROR:  column "orders.order_id" must appear in the GROUP BY clause or be used in an aggregate function
Output

DISTINCT ON fixes exactly this problem: one row per group, with all of its columns.

Example: latest order for each customer

Step 1: the basic query

SELECT DISTINCT ON (customer_id)
       customer_id, order_id, order_date, amount
  FROM orders
 ORDER BY customer_id, order_date DESC;
customer_idorder_idorder_dateamount
10132026-10-05980.00
10252026-10-01750.00
10362026-09-11300.00
1048640.00

Customers 101 and 102 look right. Two rows are wrong, and they are the two most common DISTINCT ON bugs:

  • Customer 104 got order 8, the one with no date. In descending order NULLs come first.
  • Customer 103 got order 6, but order 7 has the same date. Which of the two you get is not guaranteed.

Step 2: put NULL dates last

The documentation states that NULLs sort as if larger than any non-null value, so NULLS FIRST is the default for DESC and NULLS LAST for ASC. When you sort newest first, add NULLS LAST:

SELECT DISTINCT ON (customer_id)
       customer_id, order_id, order_date, amount
  FROM orders
 ORDER BY customer_id, order_date DESC NULLS LAST;
customer_idorder_idorder_dateamount
10132026-10-05980.00
10252026-10-01750.00
10362026-09-11300.00
10492026-07-30210.00

Customer 104 now gets order 9, the newest order that has a date.

Step 3: add a tie-breaker

Orders 6 and 7 are equal on customer_id and order_date, so the sort can return either first. The result can change after an UPDATE, a VACUUM, a new index or a different plan. Add a unique column at the end of ORDER BY so the order is fully defined:

SELECT DISTINCT ON (customer_id)
       customer_id, order_id, order_date, amount
  FROM orders
 ORDER BY customer_id, order_date DESC NULLS LAST, order_id DESC;
Final version: deterministic latest order per customer
customer_idorder_idorder_dateamount
10132026-10-05980.00
10252026-10-01750.00
10372026-09-11820.00
10492026-07-30210.00

Now the answer is the same every time: for a same-day tie, the higher order_id (the order created later) wins.

Choosing the winner by a different column

The DISTINCT ON column stays the same; only the columns after it change. To get each customer's largest order, sort by amount DESC:

SELECT DISTINCT ON (customer_id)
       customer_id, order_id, amount
  FROM orders
 ORDER BY customer_id, amount DESC, order_id;
customer_idorder_idamount
10111200.00
10243100.00
1037820.00
1048640.00

A WHERE clause is applied before DISTINCT ON, so it filters the candidates. Here cancelled orders are excluded, and customer 102 falls back to order 4:

SELECT DISTINCT ON (customer_id)
       customer_id, order_id, order_date, status
  FROM orders
 WHERE status <> 'cancelled'
 ORDER BY customer_id, order_date DESC NULLS LAST, order_id DESC;
customer_idorder_idorder_datestatus
10132026-10-05shipped
10242026-08-14delivered
10372026-09-11delivered
10492026-07-30delivered

DISTINCT ON multiple columns and expressions

List several expressions to get one row per combination. This returns the latest order for every customer and status pair:

SELECT DISTINCT ON (customer_id, status)
       customer_id, status, order_id, order_date
  FROM orders
 ORDER BY customer_id, status, order_date DESC NULLS LAST;
customer_idstatusorder_idorder_date
101delivered22026-09-20
101shipped32026-10-05
102cancelled52026-10-01
102delivered42026-08-14
103delivered62026-09-11
104delivered92026-07-30
104pending8

The expressions do not have to be plain columns. Any expression works, as long as the same expression leads the ORDER BY. This finds the biggest order in each calendar month:

SELECT DISTINCT ON (date_trunc('month', order_date))
       date_trunc('month', order_date)::date AS month,
       order_id, customer_id, amount
  FROM orders
 WHERE order_date IS NOT NULL
 ORDER BY date_trunc('month', order_date), amount DESC;
monthorder_idcustomer_idamount
2026-07-019104210.00
2026-08-0141023100.00
2026-09-0111011200.00
2026-10-013101980.00

Note on NULLs: like plain DISTINCT, DISTINCT ON treats NULL values as equal to each other, so all rows with a NULL key form one group and return one row. That is why the query above filters out the undated order first.

Sorting the final result differently

Because ORDER BY must start with the DISTINCT ON columns, the output always comes back sorted by customer. If you want the latest orders listed by amount, wrap the query in a subquery and sort outside it:

SELECT *
  FROM (
        SELECT DISTINCT ON (customer_id)
               customer_id, order_id, order_date, amount
          FROM orders
         ORDER BY customer_id, order_date DESC NULLS LAST, order_id DESC
       ) AS latest
 ORDER BY amount DESC;
customer_idorder_idorder_dateamount
10132026-10-05980.00
10372026-09-11820.00
10252026-10-01750.00
10492026-07-30210.00

Trying to do this in one level gives an error, shown in the common mistakes section below.

DISTINCT ON vs row_number()

The standard SQL way to get the same result is the window function row_number(), which numbers rows within each partition starting from 1. Keep the rows numbered 1:

SELECT customer_id, order_id, order_date, amount
  FROM (
        SELECT o.*,
               row_number() OVER (PARTITION BY customer_id
                                  ORDER BY order_date DESC NULLS LAST, order_id DESC) AS rn
          FROM orders o
       ) AS ranked
 WHERE rn = 1
 ORDER BY customer_id;
customer_idorder_idorder_dateamount
10132026-10-05980.00
10252026-10-01750.00
10372026-09-11820.00
10492026-07-30210.00

Same four rows. The advantage of row_number() is flexibility: change rn = 1 to rn <= 2 and you get the two latest orders per customer, something DISTINCT ON cannot do.

SELECT customer_id, order_id, order_date, amount
  FROM (
        SELECT o.*,
               row_number() OVER (PARTITION BY customer_id
                                  ORDER BY order_date DESC NULLS LAST, order_id DESC) AS rn
          FROM orders o
       ) AS ranked
 WHERE rn <= 2
 ORDER BY customer_id, rn;
Top 2 orders per customer
customer_idorder_idorder_dateamount
10132026-10-05980.00
10122026-09-20450.00
10252026-10-01750.00
10242026-08-143100.00
10372026-09-11820.00
10362026-09-11300.00
10492026-07-30210.00
1048640.00
DISTINCT ONrow_number()
Rows per groupExactly oneAny number (rn <= N)
Standard SQLNo, PostgreSQL onlyYes, works in Oracle, SQL Server, MySQL 8
Query lengthShort, no subqueryNeeds a subquery or CTE
Keep tied rowsNoUse rank() instead, which gives ties the same number
Final ORDER BYMust start with the DISTINCT ON columns (wrap to change)Free

If you need all tied rows (both of customer 103's orders on 2026-09-11, say), use rank() instead of row_number(): the documentation defines it as the row_number of the first row in its peer group, so tied rows get the same rank.

Performance: making DISTINCT ON fast

To test speed, the same queries were run on a table with 500,000 orders spread over 2,000 customers. Without a suitable index, PostgreSQL has to read the whole table and sort it before the Unique step can drop the duplicates:

EXPLAIN (COSTS OFF)
SELECT DISTINCT ON (customer_id) customer_id, order_id, order_date, amount
  FROM orders_big
 ORDER BY customer_id, order_date DESC, order_id DESC;
 Unique
   ->  Sort
         Sort Key: customer_id, order_date DESC, order_id DESC
         ->  Seq Scan on orders_big
Output (expected output: plan shape on PostgreSQL 18)

Step 1: an index that matches the ORDER BY

A B-tree index can return rows already sorted, which removes the Sort step. The index columns and directions must match the ORDER BY exactly:

CREATE INDEX orders_big_cust_latest_idx
    ON orders_big (customer_id, order_date DESC, order_id DESC);
 Result
   ->  Unique
         ->  Index Scan using orders_big_cust_latest_idx on orders_big
Output (expected output: plan shape on PostgreSQL 18)

The Sort is gone, but the index scan still reads all 500,000 index entries and keeps 2,000 of them. On the test machine this query and the row_number() version both took around 140 to 170 ms. If your query uses NULLS LAST, add it to the index column too (order_date DESC NULLS LAST); otherwise the index order no longer matches, and the plan gets an extra sort step again (in our test an Incremental Sort on top of the index scan).

Step 2: many rows per group? Use a LATERAL join

When each group has hundreds of rows, it is cheaper to jump straight to the top row of each group than to read every row. If you have a table with one row per customer, a LATERAL subquery with LIMIT 1 does exactly that: one short index lookup per customer.

SELECT c.customer_id, latest.order_id, latest.order_date, latest.amount
  FROM customers c
 CROSS JOIN LATERAL (
        SELECT b.order_id, b.order_date, b.amount
          FROM orders_big b
         WHERE b.customer_id = c.customer_id
         ORDER BY b.order_date DESC, b.order_id DESC
         LIMIT 1
       ) AS latest;
Latest order per customer with LATERAL
 Nested Loop
   ->  Seq Scan on customers c
   ->  Limit
         ->  Index Scan using orders_big_cust_latest_idx on orders_big b
               Index Cond: (customer_id = c.customer_id)
Output (expected output: plan shape on PostgreSQL 18)

On the same data this returned the same 2,000 rows in about 8 to 10 ms on the test machine, because it reads 2,000 index entries instead of 500,000. Your timings will differ, but the pattern holds: the fewer groups compared with rows, the bigger the gain. With a CROSS JOIN, customers without orders are left out; use LEFT JOIN LATERAL (...) ON true to keep them with NULLs.

Common mistakes

ORDER BY does not start with the DISTINCT ON columns

SELECT DISTINCT ON (customer_id) customer_id, order_id, order_date, amount
  FROM orders
 ORDER BY order_date DESC;
ERROR:  SELECT DISTINCT ON expressions must match initial ORDER BY expressions
LINE 1: SELECT DISTINCT ON (customer_id) customer_id, order_id, orde...
Output

Fix: make the DISTINCT ON columns the leading ORDER BY columns (ORDER BY customer_id, order_date DESC). If you want the result ordered by date, wrap the query in a subquery and sort outside, as shown earlier.

Leaving out ORDER BY

Without ORDER BY the query runs without error but returns whichever row PostgreSQL meets first in each group:

SELECT DISTINCT ON (customer_id) customer_id, order_id, order_date, amount
  FROM orders;
customer_idorder_idorder_dateamount
10112026-09-021200.00
10242026-08-143100.00
10362026-09-11300.00
1048640.00

This happened to return the oldest orders here, but nothing guarantees it. Always write the ORDER BY that names the winner.

Forgetting NULLS LAST and the tie-breaker

Shown in the steps above: DESC puts NULLs first, and a non-unique last sort column makes the result unpredictable. End the ORDER BY with the primary key.

Using FOR UPDATE with DISTINCT ON

SELECT DISTINCT ON (customer_id) customer_id, order_id
  FROM orders
 ORDER BY customer_id, order_date DESC
   FOR UPDATE;
ERROR:  FOR UPDATE is not allowed with DISTINCT clause
Output

The documentation lists this restriction: row locking clauses cannot be used with DISTINCT. Select the order ids first, then lock them with SELECT ... FROM orders WHERE order_id IN (...) FOR UPDATE. For queue-style locking see PostgreSQL FOR UPDATE SKIP LOCKED.

Frequently asked questions

Is DISTINCT ON faster than GROUP BY?

They answer different questions. GROUP BY with max() gives you aggregates only; DISTINCT ON gives you the whole row. When you need the full row, DISTINCT ON is usually simpler and at least as fast as joining a GROUP BY result back to the table. Check your own case with EXPLAIN ANALYZE.

Can I use DISTINCT ON with multiple columns?

Yes. DISTINCT ON (customer_id, status) returns one row per combination. The ORDER BY must start with the same columns, in the same order.

How do I count rows with DISTINCT ON?

DISTINCT ON does not aggregate. To count groups, use count(DISTINCT customer_id) (it returns 4 on the sample table), or wrap the DISTINCT ON query in a subquery and run count(*) over it.

Does DISTINCT ON work in Oracle or MySQL?

No. It is a PostgreSQL extension to the SQL standard. In Oracle, SQL Server and MySQL 8, use row_number() OVER (PARTITION BY ...) as shown above; see also SQL window functions with examples.

Summary

  • DISTINCT ON (cols) keeps one row per distinct value of cols, with all its columns.
  • ORDER BY must start with the DISTINCT ON expressions; the columns after them choose the winning row.
  • Add NULLS LAST when sorting DESC on a nullable column.
  • End ORDER BY with a unique column (usually the primary key) so the result is deterministic.
  • Wrap the query in a subquery to sort the final result by something else.
  • Use row_number() for top N per group or portable SQL, rank() to keep ties.
  • Create an index that matches the ORDER BY, and try a LATERAL ... LIMIT 1 join when groups are large.
PT Ganesh

An experienced PostgreSQL DBA/SQL developer with years of expertise in designing and implementing robust database solutions. Proficient in writing complex queries, developing stored procedures, and conducting performance tuning to deliver highly efficient data-driven applications that meet business requirements.

Home
SQL
PLSQL
Interview Q&A