Reading Data
GET /v1/rest/<project_id>/<table> reads rows. Everything is driven by query
parameters in PostgREST syntax. The Supabase clients build these for you; the raw
form is shown alongside so you understand exactly what’s sent.
Select columns
# All columnscurl "$BASE/posts" -H "apikey: $ANON_KEY"
# Specific columnscurl "$BASE/posts?select=id,title,created_at" -H "apikey: $ANON_KEY"await db.from('posts').select('id, title, created_at')db.table("posts").select("id, title, created_at").execute()await db.from('posts').select('id, title, created_at');Filtering
Filters are column=operator.value:
curl "$BASE/posts?status=eq.published&views=gte.100" -H "apikey: $ANON_KEY"Common operators:
| Operator | Meaning | Example |
|---|---|---|
eq | equals | status=eq.published |
neq | not equals | status=neq.draft |
gt gte lt lte | comparisons | views=gte.100 |
like ilike | pattern (* = %) | title=ilike.*intro* |
in | in list | id=in.(1,2,3) |
is | is null/true/false | deleted_at=is.null |
not | negate | status=not.eq.draft |
await db.from('posts') .select('*') .eq('status', 'published') .gte('views', 100) .ilike('title', '%intro%')(db.table("posts").select("*") .eq("status", "published").gte("views", 100) .ilike("title", "%intro%").execute())The complete operator reference is in REST Query Syntax.
Filtering on JSONB
Address a nested key inside a json/jsonb column with -> (returns json) and
->> (returns text) — in filters, select, and order:
curl "$BASE/docs?meta->>tier=eq.gold&select=id,meta->>tier&order=meta->>rank.desc" \ -H "apikey: $ANON_KEY"An unaliased JSON-path select item is named after its last key (meta->>tier
→ field tier). Keys must be bare identifiers or integer indices
(tags->0); quoted keys are not yet supported.
Ordering
curl "$BASE/posts?order=created_at.desc" -H "apikey: $ANON_KEY"curl "$BASE/posts?order=pinned.desc,created_at.desc" -H "apikey: $ANON_KEY"await db.from('posts').select('*') .order('pinned', { ascending: false }) .order('created_at', { ascending: false })Pagination
Two ways — limit/offset (raw) or .range() (SDK):
# rows 21–40curl "$BASE/posts?order=created_at.desc&limit=20&offset=20" -H "apikey: $ANON_KEY"// rows 0–19 (inclusive)await db.from('posts').select('*').order('created_at', { ascending: false }).range(0, 19)db.table("posts").select("*").order("created_at", desc=True).limit(20).offset(20).execute()await db.from('posts').select().order('created_at').range(0, 19);Range header
Raw HTTP clients can also page with the native PostgREST Range header (unit
items, the default). Rows are inclusive and zero-based:
# rows 0–24 (25 rows)curl "$BASE/posts?order=created_at.desc" \ -H "apikey: $ANON_KEY" -H "Range-Unit: items" -H "Range: 0-24" -i# → Content-Range: 0-24/* (and Accept-Ranges: items)Query-param limit/offset (and the SDK .range(), which sends them) always
take precedence over the Range header; a malformed or open-ended Range is
ignored and the request falls through to the default row limit below.
Row limits
Reads are capped server-side at ANVILBASE_REST_MAX_ROWS (default 1000,
matching PostgREST/Supabase db-max-rows). A GET with no limit gets
LIMIT 1000; a larger explicit limit is clamped down. The Content-Range
total still reports the true row count, so a response like 0-999/5000
signals that more rows exist — page with offset/.range()/Range to fetch
them. Operators can raise the cap or disable it (0 = unbounded) via the env
var; see Configuration.
Total counts
Ask for a count to drive pagination UIs:
curl "$BASE/posts?status=eq.published" \ -H "apikey: $ANON_KEY" -H "Prefer: count=exact" -iconst { data, count } = await db.from('posts') .select('*', { count: 'exact' }) .eq('status', 'published') .range(0, 19)count can be exact, planned, or estimated (cheaper on large tables). The
count is computed by a dedicated query against the base table, so it is correct
even when the select embeds a related table (*, posts(*)) and reflects
only the rows RLS lets the caller see.
For a count only (no rows), pass head: true — the SDK issues an HTTP
HEAD and the engine returns just the Content-Range total with an empty body:
const { count } = await db.from('posts') .select('*', { count: 'exact', head: true }) .eq('status', 'published')Single row
curl "$BASE/posts?id=eq.$ID" \ -H "apikey: $ANON_KEY" \ -H "Accept: application/vnd.pgrst.object+json"const { data: post } = await db.from('posts').select('*').eq('id', id).single()// or .maybeSingle() to allow zero rows without erroringEmbedded resources (joins)
Embed FK-related tables directly in the response — the engine resolves the foreign key and returns nested JSON, exactly like PostgREST/Supabase.
# A post with its author (many-to-one → single object) and comments# (one-to-many → array)curl "$BASE/posts?select=*,author:authors(name),comments(*)" -H "apikey: $ANON_KEY"await db.from('posts').select('*, author:authors(name), comments(*)')Nested (multi-level) embeds
Embeds nest to arbitrary depth — each level becomes a correlated subquery, and the JSON nests accordingly:
# authors → posts → comments, fully nestedcurl "$BASE/authors?select=*,posts(*,comments(*))" -H "apikey: $ANON_KEY"Nesting is capped at 5 levels deep (a DoS guard); a deeper select returns
400 (PGRST204).
Embed-level filters, order, and limit
Scope a modifier to an embedded relation by prefixing it with the embed’s name (or alias) and a dot. These apply inside the embed’s subquery — they shape the embedded rows without dropping the base row:
# Each author with only their 2 newest published postscurl "$BASE/authors?select=name,posts(title)&posts.published=eq.true&posts.order=id.desc&posts.limit=2" \ -H "apikey: $ANON_KEY"| Param | Effect |
|---|---|
<embed>.<col>=<op>.<val> | filter the embedded rows |
<embed>.order=<col>.<dir> | order the embedded rows |
<embed>.limit=N / <embed>.offset=N | paginate the embedded rows |
<embed>.<sub>.order=… | modifier on a nested embed |
In the Supabase clients these map to the referencedTable/foreignTable
option, e.g. .order('id', { referencedTable: 'posts', ascending: false }).
Many-to-many (junction tables)
When two tables are linked through a junction table (posts ↔ posts_tags ↔ tags), embed across it. If exactly one junction path exists the engine infers
it; otherwise name the junction with the ! hint:
# Inferred (unambiguous single junction)curl "$BASE/posts?select=title,tags(name)" -H "apikey: $ANON_KEY"
# Explicit junction hint (required when multiple paths exist)curl "$BASE/posts?select=title,tags!posts_tags(name)" -H "apikey: $ANON_KEY"Vector (semantic) search
For a vector column (pgvector), order by distance to a query vector — the core
of nearest-neighbor / semantic search. AnvilBase extension; see
REST Query Syntax → Vector ordering.
# The single nearest row to [1,0,0] by L2 distancecurl "$BASE/items?select=label&order=embedding.l2.[1,0,0]&limit=1" -H "apikey: $ANON_KEY"RLS and reads
What you can read is governed by Row Level Security. With the anon key you
see only rows your anon/SELECT policies permit; with a user JWT you see the
authenticated rows for that user; with service_role you bypass RLS entirely.
A fresh table with RLS on and no policy returns an empty set — that’s correct, not
a bug. See Row Level Security.
Performance tips
- Select only the columns you need (
select=...) — narrower rows, less transfer. - Index columns you filter and order on (
create index on posts (status, created_at)). - Prefer
count=estimatedon very large tables. - Use keyset pagination (
created_at=lt.<last_seen>) instead of largeoffsetvalues for deep pages.
Next: Modifying Data.