Skip to content

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

Terminal window
# All columns
curl "$BASE/posts" -H "apikey: $ANON_KEY"
# Specific columns
curl "$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:

Terminal window
curl "$BASE/posts?status=eq.published&views=gte.100" -H "apikey: $ANON_KEY"

Common operators:

OperatorMeaningExample
eqequalsstatus=eq.published
neqnot equalsstatus=neq.draft
gt gte lt ltecomparisonsviews=gte.100
like ilikepattern (* = %)title=ilike.*intro*
inin listid=in.(1,2,3)
isis null/true/falsedeleted_at=is.null
notnegatestatus=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:

Terminal window
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

Terminal window
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):

Terminal window
# rows 21–40
curl "$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:

Terminal window
# 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:

0-19/137
curl "$BASE/posts?status=eq.published" \
-H "apikey: $ANON_KEY" -H "Prefer: count=exact" -i
const { 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

Terminal window
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 erroring

Embedded resources (joins)

Embed FK-related tables directly in the response — the engine resolves the foreign key and returns nested JSON, exactly like PostgREST/Supabase.

Terminal window
# 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:

Terminal window
# authors → posts → comments, fully nested
curl "$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:

Terminal window
# Each author with only their 2 newest published posts
curl "$BASE/authors?select=name,posts(title)&posts.published=eq.true&posts.order=id.desc&posts.limit=2" \
-H "apikey: $ANON_KEY"
ParamEffect
<embed>.<col>=<op>.<val>filter the embedded rows
<embed>.order=<col>.<dir>order the embedded rows
<embed>.limit=N / <embed>.offset=Npaginate 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:

Terminal window
# 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"

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.

Terminal window
# The single nearest row to [1,0,0] by L2 distance
curl "$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=estimated on very large tables.
  • Use keyset pagination (created_at=lt.<last_seen>) instead of large offset values for deep pages.

Next: Modifying Data.