Select the First Row per Group in Supabase Postgres
The "greatest-N-per-group" problem: one representative row per group. Postgres solves it with DISTINCT ON; supabase-js can't express that directly, so you wrap it in a view or an RPC function. Both confirmed by Supabase maintainers.
You want the most recent order per customer, or the highest-scoring row per team. You reach for GROUP BY... and hit a wall: GROUP BY returns aggregates, not the full non-aggregated row that achieved the max. This is the classic greatest-N-per-group problem, and PostgreSQL has a clean answer that supabase-js can't express directly.
The idiomatic way: DISTINCT ON#
PostgreSQL's DISTINCT ON "keeps only the first row of each set of rows where the given expressions evaluate to equal." The most-recent-row-per-group query:
SELECT DISTINCT ON (customer_id) customer_id, id, total, created_at
FROM orders
ORDER BY customer_id, created_at DESC;The ORDER BY must lead with the same column(s) as DISTINCT ON, then add the tiebreaker that decides the winner. From the Postgres docs: "The DISTINCT ON expression(s) must match the leftmost ORDER BY expression(s)" and "the 'first row' of each set is unpredictable unless ORDER BY is used."
So ORDER BY customer_id, created_at DESC = "group by customer, and within each group the newest order wins." Drop the created_at DESC and the result becomes non-deterministic.
The portable way: ROW_NUMBER() in a subquery#
If you want top-N (not just top-1), or you prefer SQL that ports to other databases, use a window function:
SELECT customer_id, id, total
FROM (
SELECT customer_id, id, total,
ROW_NUMBER() OVER (PARTITION BY customer_id ORDER BY created_at DESC) AS rn
FROM orders
) ranked
WHERE rn = 1; -- change to <= 3 for the three latest per customerYou must wrap it in a subquery. The Postgres docs are explicit: window functions "are permitted only in the SELECT list and the ORDER BY clause... They are forbidden... in WHERE clauses, [because] they logically execute after the processing of those clauses." Add a secondary column to the window's ORDER BY to break ties deterministically.
The often-fastest way: LATERAL#
When you have a small driving set of groups and a good index, a LATERAL join does an index top-1 lookup per group instead of scanning and sorting everything:
SELECT c.id AS customer, top.*
FROM customers c
CROSS JOIN LATERAL (
SELECT o.id, o.total
FROM orders o
WHERE o.customer_id = c.id
ORDER BY o.created_at DESC
LIMIT 1
) AS top;Use LEFT JOIN LATERAL ... ON true if you want customers with zero orders to still appear.
Which approach wins depends on your data and indexes — there is no universal answer. The single most useful index for all three is a composite on (group_col, tiebreaker DESC). Confirm the plan with EXPLAIN ANALYZE rather than trusting a blog (including this one).
Running it from Supabase#
Here's the catch: the supabase-js query builder cannot issue DISTINCT ON (nor SELECT DISTINCT). In the official Supabase discussion, maintainers say it directly — "You can create a view to achieve it!" and "You can use a function for that. All filters work the same on functions (RPC) as on views."
So you write the SQL once in the database, then call it from the client.
Option A — a VIEW (best for fixed, parameter-free queries)#
create view latest_order_per_customer as
select distinct on (customer_id) customer_id, id, total, created_at
from orders
order by customer_id, created_at desc;// query it exactly like a table
const { data, error } = await supabase
.from('latest_order_per_customer')
.select('*')Option B — an RPC function (best when you need parameters)#
create or replace function latest_orders(min_total numeric)
returns setof orders
language sql
as $$
select distinct on (customer_id) *
from orders
where total >= min_total
order by customer_id, created_at desc;
$$;const { data, error } = await supabase.rpc('latest_orders', { min_total: 100 })- You need it filtered by per-request parameters — a plain view can't take args; use an RPC function instead.
- Your table is huge with no index on
(group_col, tiebreaker)— all three approaches degrade to a full sort. Add the index first.
Supabase even has a dedicated guide that hands you the DISTINCT ON SQL: Select first row for each group. Official references: PostgreSQL SELECT / DISTINCT ON, window functions, supabase-js rpc().
Related Articles#
- How to Get a Row Count in Supabase
- Return the Inserted Row ID in supabase-js
- Supabase Postgres Functions & Triggers Guide
- How to Query Using JOIN in Supabase
Frequently Asked Questions#
Can supabase-js do SELECT DISTINCT ON?#
No — the query builder has no DISTINCT method. Maintainers recommend a database VIEW (query it like a table) or an RPC function (call with rpc()).
How do I get the latest row per group in Postgres?#
SELECT DISTINCT ON (group_col) * FROM t ORDER BY group_col, created_at DESC. The ORDER BY must start with the DISTINCT ON column, then the tiebreaker that picks the winner.
DISTINCT ON or ROW_NUMBER()?#
DISTINCT ON is most concise. ROW_NUMBER() in a subquery is more portable and supports top-N. LATERAL can be fastest with few groups and a good index. Measure with EXPLAIN ANALYZE.
Frequently Asked Questions
One email a month — no fluff
RLS gotchas, Next.js cache debugging, and the one Supabase setting that bit me last month.
Continue Reading
Fix Foreign Key Constraint Violation in Supabase (23503)
23503 means a foreign key relationship is broken. Inserting a child before its parent? Insert the parent first. Can't delete a parent with children? Choose an ON DELETE action. The most common Supabase case is a profiles row referencing auth.users.
Fix Supabase RLS Infinite Recursion Error (Postgres Policy Loop Explained — Production Fix 2026)
If your Supabase query returns `infinite recursion detected in policy for relation "X"`, your RLS policy is querying the same table it protects. Here's exactly why it loops, and three production-grade fixes that don't leak data.
Why Your Supabase Queries Are Slow (And Exactly How to Fix Them)
Slow Supabase queries kill your app feel and inflate your bill. Here are the six causes I keep seeing in production apps, and the exact SQL and code fixes for each one.
Browse by Topic
Find stories that matter to you.
