Querying and filtering data

Read, filter, sort, paginate, join, insert, update, upsert and delete rows through the SupaHost Data API with supabase-js, with the equivalent REST calls and the 1000-row limit.

supabase-js builds Data API requests for you: supabase.from('table') starts a query, and each method you chain adds a filter, a sort or a limit. Nothing is sent until you await it. This page shows the patterns you'll use most, using a bookings app with rooms, bookings and profiles tables.

Every query runs as anon or as the signed-in user, so RLS policies filter the results before any of your filters apply.

Selecting columns

// specific columns (smaller responses, less egress)
const { data, error } = await supabase.from('rooms').select('id, name, capacity')

// rename a column in the result
const { data: rooms } = await supabase.from('rooms').select('id, label:name')

Always check error. A failed query resolves with { data: null, error }; it doesn't throw.

Filters

const { data } = await supabase
  .from('bookings')
  .select('id, room_id, starts_at')
  .eq('room_id', 3)
  .gte('starts_at', '2026-11-01')
  .lt('starts_at', '2026-12-01')
Method SQL meaning
.eq(col, v) / .neq(col, v) = / <>
.gt .gte .lt .lte > >= < <=
.in(col, [a, b]) in (a, b)
.like(col, '%x%') / .ilike(...) pattern match, case-sensitive / insensitive
.is(col, null) is null (also true / false)
.not(col, 'is', null) negate any filter
.contains(col, v) / .containedBy(...) array or JSONB containment
.textSearch(col, 'query') full-text search, see Full-text search
.or('a.eq.1,b.eq.2') a = 1 or b = 2

Chained filters are combined with AND. For OR, write the conditions in PostgREST syntax inside .or():

const { data } = await supabase
  .from('rooms')
  .select('id, name')
  .or('capacity.gte.10,has_projector.is.true')

Inside .or(), values containing commas or brackets need double quotes. See column does not exist when using or() filters.

Sorting and limiting

const { data } = await supabase
  .from('bookings')
  .select('id, starts_at')
  .order('starts_at', { ascending: false })
  .limit(10)

Pagination and the 1000-row limit

A single request returns at most 1000 rows on SupaHost. Asking for more, or not asking for a limit at all, still gives you at most 1000, without an error. Page with .range(from, to), which is zero-based and inclusive:

const pageSize = 100
const page = 2 // third page

const { data, count } = await supabase
  .from('bookings')
  .select('id, starts_at', { count: 'exact' })
  .order('id')
  .range(page * pageSize, page * pageSize + pageSize - 1)

count: 'exact' returns the total number of matching rows alongside the page. On large tables 'estimated' is much cheaper. To only count, add head: true and no rows are returned.

For deep pagination on big tables, filter by the last id you saw instead of using large offsets:

const { data } = await supabase
  .from('bookings')
  .select('id, starts_at')
  .gt('id', lastSeenId)
  .order('id')
  .limit(100)

Single rows

// exactly one row, or an error
const { data: room, error } = await supabase.from('rooms').select('*').eq('id', 3).single()

// zero or one row, no error when missing
const { data: maybe } = await supabase.from('profiles').select('*').eq('id', userId).maybeSingle()

If a foreign key links two tables, name the related table inside select:

// many-to-one: each booking with its room
const { data } = await supabase
  .from('bookings')
  .select('id, starts_at, room:rooms ( name, capacity )')

// one-to-many: each room with its bookings
const { data: busy } = await supabase
  .from('rooms')
  .select('name, bookings ( starts_at, ends_at )')
  .gte('bookings.starts_at', '2026-11-12')

A filter on the joined table (bookings.starts_at) filters the nested list, not the parent rows. To keep only parents that have matching children, use an inner join:

const { data } = await supabase
  .from('rooms')
  .select('name, bookings!inner ( starts_at )')
  .eq('bookings.user_id', userId)

When two foreign keys point at the same table, say which one: profiles!bookings_user_id_fkey ( display_name ). RLS applies to joined tables too, so a related row the user can't see comes back as null or is left out.

Inserting, updating and deleting

// insert one or many; .select() returns the new rows
const { data: created, error } = await supabase
  .from('bookings')
  .insert({ room_id: 3, starts_at: '2026-11-20T09:00:00Z', ends_at: '2026-11-20T10:00:00Z' })
  .select()
  .single()

// update matching rows
await supabase.from('bookings').update({ room_id: 4 }).eq('id', created!.id)

// upsert on the primary key (or another unique column)
await supabase
  .from('profiles')
  .upsert({ id: userId, display_name: 'Lee' }, { onConflict: 'id' })

// delete
await supabase.from('bookings').delete().eq('id', created!.id)

Always give update and delete a filter. Without one, they target every row your policies allow. RLS still applies: if a policy hides a row, the update or delete simply affects zero rows, with no error. Insert and update also need a select policy if you chain .select().

The same requests over HTTP

Each supabase-js call maps to a REST request, which is useful from other languages or for debugging:

curl "https://app-one.acme.supahost.space/rest/v1/bookings?select=id,starts_at,room:rooms(name)&room_id=eq.3&order=starts_at.desc&limit=10" \
  -H "apikey: $SUPABASE_ANON_KEY" \
  -H "Authorization: Bearer $USER_ACCESS_TOKEN"

Filters are query parameters (column=operator.value), and pagination can also use the Range: 0-99 header. See the PostgREST API reference and the supabase-js reference.

When a query doesn't fit

Aggregates across many rows, multi-step writes that must succeed together, or queries with window functions are easier as a database function you call with .rpc(). See Calling database functions (RPC) and Operations too complex for the client library.

Updated 11 November 2026 · View as Markdown · Something wrong or missing? Tell us