Skip to content

Querying

Filter, embed, aggregate, and paginate with the PostgREST-compatible query API.

instancez exposes the same HTTP API as Supabase, so any Supabase client library works — JavaScript, Python, Swift, Flutter, and others. Examples here use @supabase/supabase-js (which is also what the integration tests run against), but the raw HTTP parameters are shown so you can use any client or make requests directly.

Fetch all columns:

const { data, error } = await supabase.from('todos').select('*')
// GET /rest/v1/todos?select=*

List queries default to LIMIT 20 when no .limit()/.range() is given — use pagination (below) to get more than 20 rows back.

Fetch specific columns:

const { data, error } = await supabase.from('todos').select('id, title, done')
// GET /rest/v1/todos?select=id,title,done

Alias a column in the response:

const { data, error } = await supabase.from('todos').select('label:title, done')
// GET /rest/v1/todos?select=label:title,done
// response key is "label", not "title"

Cast a column type:

// GET /rest/v1/todos?select=id::text,title

Filters are query parameters of the form column=operator.value.

OperatorMeaningExample
eqequalpriority=eq.3
neqnot equalpriority=neq.3
gtgreater thanpriority=gt.3
gtegreater than or equalpriority=gte.3
ltless thanpriority=lt.3
lteless than or equalpriority=lte.3
likeSQL LIKE (case-sensitive, % wildcard)title=like.%milk%
ilikeSQL ILIKE (case-insensitive)title=ilike.%MILK%
matchregex match (~)title=match.^buy
imatchcase-insensitive regex (~*)title=imatch.^BUY
isIS NULL / IS TRUE / IS FALSE / IS UNKNOWNdone=is.false
isdistinctIS DISTINCT FROM (NULL-safe not-equal)title=isdistinct.null
inset membershipstatus=in.(active,pending)
// eq
const { data } = await supabase.from('todos').select('*').eq('done', false)
// neq
const { data } = await supabase.from('todos').select('*').neq('priority', 3)
// gt / gte / lt / lte
const { data } = await supabase.from('todos').select('*').gt('priority', 3)
const { data } = await supabase.from('todos').select('*').lte('priority', 2)
// like / ilike
const { data } = await supabase.from('todos').select('*').like('title', '%milk%')
const { data } = await supabase.from('todos').select('*').ilike('title', '%MILK%')
// is
const { data } = await supabase.from('todos').select('*').is('done', false)
// in
const { data } = await supabase.from('todos').select('*').in('status', ['active', 'pending'])
# match ALL patterns (LIKE ALL)
title=like(all).{%milk%,%eggs%}
# match ANY pattern (LIKE ANY)
title=like(any).{%milk%,%eggs%}
# case-insensitive variants
title=ilike(all).{%MILK%,%EGGS%}
title=ilike(any).{%MILK%,%EGGS%}

Prefix any operator value with not. to negate it:

// not equal to 3
const { data } = await supabase.from('todos').select('*').not('priority', 'eq', 3)
// URL: priority=not.eq.3
// OR: rows where priority is 1 or 5
const { data } = await supabase
.from('todos')
.select('*')
.or('priority.eq.1,priority.eq.5')
// URL: or=(priority.eq.1,priority.eq.5)

Nested logic:

or=(title.like.%milk%,and(priority.gt.3,done.is.false))

These apply to Postgres array and range types:

OperatorSQLMeaning
cs@>array/range contains value
cd<@array/range is contained by
ov&&arrays/ranges overlap
sl<<range strictly left of
sr>>range strictly right of
nxl&>range does not extend left of
nxr&<range does not extend right of
adj`--`
tags=cs.{urgent,blocked}
price_range=ov.[10,50]
// fts → to_tsquery
const { data } = await supabase.from('todos').select('*').textSearch('title', 'milk')
// URL: title=fts.milk

Other FTS operators (use via raw URL parameters):

OperatorPostgres function
ftsto_tsquery
plftsplainto_tsquery
phftsphraseto_tsquery
wftswebsearch_to_tsquery

Pass a language config in parentheses: title=fts(english).milk.

Access nested JSONB values using -> (returns jsonb) and ->> (returns text):

metadata->>theme=eq.dark
settings->notifications->>enabled=eq.true
// ascending (default)
const { data } = await supabase.from('todos').select('*').order('priority')
// descending
const { data } = await supabase.from('todos').select('*').order('priority', { ascending: false })
// nulls first / nulls last (use URL param directly)
// order=priority.desc.nullslast

Multiple columns: order=priority.asc,created_at.desc.

const { data } = await supabase.from('todos').select('*').order('priority').limit(10)
// URL: order=priority&limit=10&offset=0

The client can request a slice with an HTTP Range header (Range-Unit: items). supabase-js exposes this via .range():

// rows 2–3 (zero-based, inclusive)
const { data } = await supabase.from('todos').select('*').order('priority').range(2, 3)

The response includes a Content-Range header: 2-3/* (or 2-3/N when a count is requested).

Pass Prefer: count=exact to get the total row count alongside (or instead of) the rows:

const { data, count } = await supabase
.from('todos')
.select('*', { count: 'exact' })

Use head: true to skip the body and return only the count:

const { count } = await supabase
.from('todos')
.select('*', { count: 'exact', head: true })

Count modes:

ModeBehavior
exactCOUNT(*) — precise but adds a query
plannedUses the Postgres query planner estimate
estimatedpg_class.reltuples statistic when no filters exist, planner estimate (via EXPLAIN) when filters exist. Never executes COUNT(*).

Embeds use foreign key relationships declared in instancez.yaml to join related tables in a single request.

When the current table has a foreign key pointing to another table, the joined row is returned as an object:

// comments has a FK: todo_id → todos.id
const { data } = await supabase.from('comments').select('body, todos(title)')
// response: [{ body: "...", todos: { title: "..." } }, ...]

When another table has a FK pointing back to the current table, the joined rows are returned as an array:

// todos has many comments
const { data } = await supabase.from('todos').select('title, comments(body)')
// response: [{ title: "...", comments: [{ body: "..." }, ...] }, ...]

By default, embeds use a LEFT join — rows with no matching related record are still returned (the embed key is null). Use !inner to drop those rows:

// only todos that have at least one comment
// GET /rest/v1/todos?select=title,comments!inner(body)

Rename the embed key in the response:

// parent:todos is returned under "parent", not "todos"
const { data } = await supabase.from('comments').select('body, parent:todos(id, title)')

The !left modifier is accepted (explicit left join, the default) and can be combined with an alias: parent:todos!left(id,title).

When two FKs exist between the same tables, use !fk_column to pick the right one:

select=title,assignee:users!assignee_id(name)

Inline the joined columns directly into the parent row (belongs-to only):

GET /rest/v1/comments?select=body,...todos(title)
// response: [{ body: "...", title: "..." }, ...]

Filter, order, or paginate within a has-many embed using <embed>. prefixes:

GET /rest/v1/todos?select=title,comments(body)&comments.body=like.%important%&comments.order=created_at.desc&comments.limit=5
const { data } = await supabase
.from('todos')
.select('title, comments(body, todos(title))')

Use PostgREST-style aggregate suffixes in the select parameter. When any aggregate is present, the query groups by all non-aggregate columns automatically.

col.agg() — aggregate over a column
alias:col.agg() — explicit alias
col.agg()::type — cast the result
count() — COUNT(*) with no column

Supported aggregates: count, sum, avg, min, max.

// count rows per status
// GET /rest/v1/todos?select=status,count()
// response: [{ status: "active", count: 3 }, ...]
// sum of a column
// GET /rest/v1/todos?select=status,total:priority.sum()
// average with cast
// GET /rest/v1/todos?select=avg_priority:priority.avg()::numeric

Filter on aggregate results with the having parameter:

GET /rest/v1/todos?select=status,total:id.count()&having=total.gt.2

Reference the aggregate alias in the order parameter:

GET /rest/v1/todos?select=status,id.count()&order=count.desc
const { data, error } = await supabase
.from('todos')
.insert({ title: 'buy milk', user_id: userId })
.select()

Bulk insert:

const { data, error } = await supabase
.from('todos')
.insert([
{ title: 'buy milk', user_id: userId },
{ title: 'buy eggs', user_id: userId },
])
.select('id, title')

Control what the server returns with Prefer: return=:

ValueBehavior
minimalNo body returned (default)
headers-onlyStatus + headers only (201, no body)
representationFull row(s) returned
// merge on PK conflict
const { data } = await supabase
.from('todos')
.upsert({ id: existingId, title: 'updated title' })
.select()
// ignore on PK conflict
const { data } = await supabase
.from('todos')
.upsert({ id: existingId, title: 'ignored' }, { ignoreDuplicates: true })

To upsert on a non-PK column, pass on_conflict=column_name in the query string and Prefer: resolution=merge-duplicates or resolution=ignore-duplicates.

const { error } = await supabase
.from('todos')
.update({ done: true })
.eq('user_id', userId)
const { error } = await supabase
.from('todos')
.delete()
.eq('id', todoId)

Prevent accidentally broad mutations with Prefer: max-affected=N. The request is rejected (rolled back) if more than N rows would be affected:

Prefer: max-affected=1

Roll back the transaction after the query executes without committing changes:

Prefer: tx=rollback

Add .csv() in supabase-js or set Accept: text/csv to receive results as CSV:

const { data } = await supabase.from('todos').select('title').order('priority').csv()
  • RLS — control access to rows with row-level security policies
  • Auth — configure authentication and JWT handling
  • RPC reference — call Postgres functions over HTTP