Querying with the Client SDK
The idea in one sentence
Section titled “The idea in one sentence”supabase-js’s query builder is a fluent TypeScript wrapper that builds the exact same PostgREST request you could write by hand, and every call resolves to a { data, error } object instead of throwing.
The query builder mirrors the REST API
Section titled “The query builder mirrors the REST API”Everything you saw as a raw query string in the previous lesson has a matching method on the query builder. Pick a table with .from(), choose columns with .select(), filter with .eq() (and its siblings), and sort with .order():
const { data, error } = await supabase .from('movies') .select('id, title, year') .eq('year', 2020) .order('year', { ascending: false });Writes work the same way — .insert(), .update(), and .delete() map to the corresponding HTTP verbs PostgREST exposes:
await supabase.from('movies').insert({ title: 'Arrival', year: 2016, director_id: 3 });
await supabase.from('movies').update({ year: 2021 }).eq('id', 1);
await supabase.from('movies').delete().eq('id', 1);Checking data and error explicitly
Section titled “Checking data and error explicitly”A supabase-js query never throws on a failed request — it always resolves with an object shaped { data, error }. If the request succeeds, error is null and data holds the rows. If it fails, data is null and error is populated. Because there is no exception to catch, you must check error yourself on every call, or a failed request will silently look like an empty result.
error is a PostgrestError with four fields worth knowing:
- message — a human-readable description of what went wrong.
- code — either a Postgres error code (e.g. a unique-constraint violation) or a PostgREST-specific code.
- details — additional context from Postgres, when available.
- hint — a suggestion for fixing the problem, when Postgres or PostgREST can offer one.
const { data, error } = await supabase.from('movies').select('id, title').eq('year', 2020);
if (error) { console.error(`query failed [${error.code}]: ${error.message}`);} else { console.log(data);}Embedding related tables through the builder
Section titled “Embedding related tables through the builder”Just like the raw REST API, the query builder can embed a related table by naming it inside select() — PostgREST still does the work by following the foreign key, the builder just gives you a typed, chainable way to ask for it:
const { data, error } = await supabase.from('movies').select('title, directors(name)');Filters and operators, running for real
Section titled “Filters and operators, running for real”Every filter operator from the raw REST API has a matching builder method — .eq(), .gte()/.lte(), .like()/.ilike(), .in(), and more — each one appending exactly the column=operator.value query parameter shown in the previous lesson. Run against the real agapi_movies table from that lesson:
// @runimport { createClient } from '@supabase/supabase-js';
declare const process: { env: Record<string, string | undefined> };
const supabase = createClient(process.env.SUPABASE_URL!, process.env.SUPABASE_ANON_KEY!);
const { data: recent } = await supabase .from('agapi_movies') .select('title,year') .gte('year', 2021) .order('year');console.log('gte 2021:', recent);
const { data: matches } = await supabase .from('agapi_movies') .select('title') .ilike('title', '%things%');console.log('ilike %things%:', matches);
const { data: inList } = await supabase .from('agapi_movies') .select('title,year') .in('year', [2021, 2023]);console.log('in [2021,2023]:', inList);gte 2021: [ { title: 'Dune', year: 2021 }, { title: 'Barbie', year: 2023 }, { title: 'Poor Things', year: 2023 }, ... ]ilike %things%: [ { title: 'Poor Things' } ]in [2021,2023]: [ { title: 'Dune', year: 2021 }, { title: 'Barbie', year: 2023 }, { title: 'Poor Things', year: 2023 }, ... ].ilike() is the case-insensitive sibling of .like() — PostgREST’s own ilike. operator, not a client-side filter; the query still ran entirely in Postgres.
Pagination: .range() does not use the HTTP Range header
Section titled “Pagination: .range() does not use the HTTP Range header”PostgREST supports two independent pagination mechanisms (per its own docs — docs.postgrest.org — Pagination and Count): the HTTP Range/Range-Unit request headers, and plain limit/offset query parameters. Given the name, it is easy to assume supabase-js’s .range(from, to) sends the Range header — it does not. Reading the method’s own source (postgrest-js/src/PostgrestTransformBuilder.ts, current version bundled with this course) shows exactly what it sets:
this.url.searchParams.set(keyOffset, `${from}`)// Range is inclusive, so add 1this.url.searchParams.set(keyLimit, `${to - from + 1}`).range() is named after the concept of a range, but implemented with the query-parameter pagination method, not the header one — confirmed by logging the exact request URL it builds before it is sent:
// @runimport { createClient } from '@supabase/supabase-js';
declare const process: { env: Record<string, string | undefined> };
const supabase = createClient(process.env.SUPABASE_URL!, process.env.SUPABASE_ANON_KEY!);
const query = supabase.from('agapi_movies').select('id,title').order('id').range(0, 1);// `.url` is a protected member of PostgrestBuilder — not part of the public// API, only readable here (via a cast) as a one-off debugging trick to prove// exactly what request .range() builds.console.log('request URL:', (query as unknown as { url: URL }).url.toString());
const { data } = await query;console.log('data:', data);request URL: http://127.0.0.1:55321/rest/v1/agapi_movies?select=id%2Ctitle&order=id.asc&offset=0&limit=2data: [ { id: 1, title: 'Dune' }, { id: 2, title: 'Barbie' } ]No Range header anywhere — range(0, 1) (0-based, inclusive, per the method’s own docstring) became offset=0&limit=2. The HTTP Range header pagination style still exists and PostgREST still honors it (proved in the previous lesson’s Prefer: count=... requests, which used plain query-string limit), but no supabase-js method sends it — if you need it specifically (e.g. matching an existing Range-based client), you reach for .select() combined with a raw fetch, not the query builder.
What PostgREST actually sends to Postgres
Section titled “What PostgREST actually sends to Postgres”Every query builder call — filters, embeds, pagination — ultimately becomes one SQL statement PostgREST hands to Postgres inside a single transaction. This local stack has pg_stat_statements enabled by default (confirmed with select * from pg_available_extensions where name = 'pg_stat_statements' — installed_version was already set, no create extension needed here), so the exact generated SQL for a real request is directly inspectable — no log_statement=all change to the shared server needed:
-- sql/agapi_check_pg_stat_statements.sqlselect pg_stat_statements_reset();curl "$API_URL/rest/v1/agapi_movies?select=id,title&order=id&limit=2&offset=0" \ -H "apikey: $ANON_KEY" -H "Authorization: Bearer $ANON_KEY" -H "Prefer: count=exact"-- sql/agapi_read_pg_stat_statements.sqlselect query from pg_stat_statements where query ilike '%agapi_movies%' limit 1;WITH pgrst_source AS ( SELECT "public"."agapi_movies"."id", "public"."agapi_movies"."title" FROM "public"."agapi_movies" ORDER BY "public"."agapi_movies"."id" LIMIT $1 OFFSET $2 ) ,pgrst_source_count AS (SELECT $3 FROM "public"."agapi_movies")SELECT (SELECT pg_catalog.count(*) FROM pgrst_source_count) AS total_result_set, pg_catalog.count(_postgrest_t) AS page_total, coalesce(json_agg(_postgrest_t), $4) AS body, nullif(current_setting($5, $6), $7) AS response_headers, nullif(current_setting($8, $9), $10) AS response_status, $11 AS response_insertedFROM ( SELECT * FROM pgrst_source ) _postgrest_tThe query builder call, the raw REST URL, and this SQL are three views of the exact same request. A few things worth noticing directly in it: the real table/column names are double-quoted and schema-qualified ("public"."agapi_movies"."id", never a bare agapi_movies); values are bound as $1/$2/… parameters, not string-concatenated — the entire query language is immune to SQL injection by construction, because filter values never become SQL text; the extra pgrst_source_count CTE only appears because Prefer: count=exact was set — the response’s total_result_set requires a genuine second count(*), which is exactly the cost count=exact warned about in the previous lesson, now visible as a second CTE instead of an abstract claim. A full walkthrough of this translation — every clause, every operator — belongs to a level below what supabase-js needs day to day; see /supabase/en/supabase-internals/postgrest-request-to-sql/ for that.
A complete example
Section titled “A complete example”import { createClient } from '@supabase/supabase-js';
const supabase = createClient(process.env.SUPABASE_URL!, process.env.SUPABASE_ANON_KEY!);
async function recentMovies() { const { data, error } = await supabase .from('movies') .select('id, title, year, directors(name)') .eq('year', 2020) .order('title', { ascending: true });
if (error) { console.error('failed to load movies:', error.message); return []; }
return data;}flowchart LR
from[".from('movies')"] --> select[".select('id, title, year')"]
select --> eq[".eq('year', 2020)"]
eq --> order[".order('year')"]
order --> http["One HTTP request to PostgREST"]
http --> result["Result: data and error"] Reading AI-generated code
Section titled “Reading AI-generated code”An AI assistant, told “load page 3 of movies, 10 per page, using the Range header like PostgREST expects,” wrote this:
🐛 Spot the bug
import { createClient } from '@supabase/supabase-js';
const supabase = createClient(process.env.SUPABASE_URL!, process.env.SUPABASE_ANON_KEY!);
async function getPage(pageNumber: number, pageSize = 10) {const from = (pageNumber - 1) * pageSize;const to = from + pageSize;
const { data, error, count } = await supabase .from('agapi_movies') .select('id, title, year', { count: 'exact' }) .range(from, to);
if (error) throw error;return { data, totalPages: Math.ceil((count ?? 0) / pageSize) };}Show the answer
Two bugs, one about the off-by-one in range(), one about a false assumption baked into the prompt itself. First: .range() is documented as 0-based and inclusive — range(from, to) requests to - from + 1 rows, not to - from. For page 3 with pageSize = 10, from is 20, and the code computes to as from + pageSize, i.e. 30 — that is 11 rows (20 through 30 inclusive), not 10, silently drifting every page’s boundary by one row across the whole paginated list. The fix is to = from + pageSize - 1. Second, and this is the “Range header” framing from the prompt itself misleading the implementation: nothing here uses the actual HTTP Range header — proved above, .range() sends limit/offset query parameters. That is not a bug in this code (the request still works correctly, off-by-one aside), but it means a comment or teammate description claiming this “uses the Range header” is simply wrong, and debugging a real Range-header-specific server behavior (a proxy or CDN rewriting it, say) by reading this client code will lead nowhere, because the header this code sends is never that one.
Mental Model
Section titled “Mental Model”The query builder is a thin, typed URL-and-header assembler — nothing in it talks to Postgres directly, and nothing in it is executed until the object is await-ed (proved above: the URL was fully built and inspectable before the request fired). Every chained method — .eq(), .range(), .select() — mutates the same underlying URL object’s search params or the request’s headers, and the final await is the one moment an HTTP request actually leaves the process. This is why logging query.url.toString() before awaiting is such a useful debugging habit: it shows you, with total certainty, the exact REST request about to be sent — no guessing about what a chain of methods “probably” does.
Trade-offs
Section titled “Trade-offs”- The query builder is typed and chainable — at the cost of method names that do not always map obviously to wire behavior:
.range()sounds like theRangeheader; it is not, proved above, and the only way to be sure is reading the source or logging the URL. { data, error }never throws, so a forgottenif (error)fails visibly as wrong data rather than an uncaught exception — at the cost of that safety being opt-in: nothing forces the check, and a forgotten one degrades to “looks like an empty result,” which is easy to misdiagnose as “no matching rows” instead of “the request failed.”- Filter methods read naturally in TypeScript (
.gte('year', 2021)) — at the cost of hiding that every one of them is still exactly the REST operator syntax underneath (year=gte.2021); reasoning about what a chain will actually do in Postgres benefits from being able to read both forms interchangeably, not just the TypeScript one.
Common Misconceptions
Section titled “Common Misconceptions”- “
.range()sends the HTTPRangeheader, because that’s what it’s named after.” Proved false directly from the library’s own source and a logged request URL — it sendslimit/offsetquery parameters, PostgREST’s other, separate pagination mechanism. - “If
.select()returnsdata: null, there were no matching rows.”dataisnullon failure; zero matching rows isdata: [], a non-null empty array, witherror: null. Conflating the two hides real failures as “no results.” - “The query builder is a separate query language from raw PostgREST URLs.” It is a thin builder over the identical URL syntax from the previous lesson — every method call appends the same query string a hand-written
curlrequest would use, provable by logging.url.toString()before awaiting. - “Reading a
pg_stat_statementsentry for a Data API request would need special server config.” Proved above: it is enabled by default on this local stack already — nolog_statement=allor other shared-config change was needed to see the real generated SQL.