Database — PostgREST
Supabase exposes your Postgres database over HTTP through a layer called
PostgREST — it turns each table into a web endpoint you can read from and
write to, with your database’s permission rules (RLS, Row Level Security)
enforced on the server so each user only sees what they’re allowed to. The
supabase-database module gives you a typed Kotlin way to drive that: think of
it as querying your tables in Kotlin instead of writing SQL or hand-building HTTP
requests.
It offers a typed CRUD surface (create, read, update, delete), a Kotlin filter
DSL for building WHERE conditions, and RPC for calling database functions.
Every method comes in two flavours: a typed variant that deserializes the
response into your @Serializable model, and a raw variant that hands you
back the JSON string for when you need full control.
Start by building a database client from your SupabaseClient, and define a
model that matches the shape of a row in your table:
val database = createDatabaseClient(client)
@Serializable
data class Todo(val id: String, val title: String, val done: Boolean)Every call returns a SupabaseResult — see Results & Errors
for how to handle the success and failure branches.
Select
Reading rows is the most common thing you’ll do. selectTyped<Todo> fetches rows
from a table and deserializes them into a list of your model. The trailing { }
block is where you narrow the results down — filter, sort, and limit — which we
cover in detail under Filter DSL below.
val todos: SupabaseResult<List<Todo>> = database.selectTyped<Todo>(table = "todos") {
eq("done", false)
order("created_at", ascending = false)
limit(25)
}Pick the read method that matches how many rows you expect back:
| Method | Returns | Use |
|---|---|---|
selectTyped<T>(table, columns, …) { filters } | List<T> | the common case |
selectSingleTyped<T>(…) { … } | T | expects exactly one row |
selectMaybeSingleTyped<T>(…) { … } | T? | 0 or 1 row |
selectCsv(…) { … } | String (CSV) | export |
selectHead(…) { … } | Unit (+ count) | counting only |
select(table, …) { … } | String (raw JSON) | escape hatch |
columns accepts PostgREST projection syntax — "*", "id,title", or embedded
relations like "*,author(*)". The typed read helpers all accept an optional
schema. For PostGIS rows as a GeoJSON FeatureCollection, use selectGeoJson(…)
(or the raw select(…, geojson = true)).
Insert, Update, Upsert, Delete
These are the writes. insert adds new rows, update changes existing ones,
delete removes them, and upsert means “insert-or-update” — if a row with the
same key already exists it’s updated instead of creating a duplicate (handy for
syncing, since running the same upsert twice is safe — it’s idempotent). Note
that update and delete take a filter block to choose which rows they affect.
// A partial model for updates — only the columns you want to change:
@Serializable
data class TodoPatch(val done: Boolean)
database.insertTyped(table = "todos", value = Todo("1", "Ship it", false))
database.insertTypedMany(table = "todos", values = listOf(/* … */))
database.updateTyped(table = "todos", value = TodoPatch(done = true)) {
eq("id", "1")
}
database.upsertTyped(table = "todos", value = todo, onConflict = "id")
database.deleteTyped<Todo>(table = "todos") { eq("id", "1") }Writes come in typed, raw-string, and *Unit (discard the response body) forms;
insert and upsert additionally have *TypedMany for batch inserts. insert
accepts upsert; both insert and upsert accept upsertResolution
(MERGE_DUPLICATES / IGNORE_DUPLICATES), onConflict, and defaultToNull.
The tuning options returning (REPRESENTATION / MINIMAL), count, and
maxAffected live on the raw interface methods (insert / update /
delete / rpc) — the typed helpers expose a reduced subset, so drop down to
the raw form when you need them.
Filter DSL
This is how you build the WHERE part of a query in Kotlin instead of SQL. All
operators run inside the filters: FilterBuilder.() -> Unit lambda — the { }
block you saw on selectTyped, update, and delete. The mental model: each
call you add inside the block is another condition, and multiple calls combine
with AND (every condition must match). Use the explicit or { } / and { }
helpers when you need different grouping.
Comparison
The everyday operators — equals, not-equals, and the greater/less-than family:
eq("status", "active"); neq("role", "admin")
gt("age", 18); gte("age", 21); lt("score", 100); lte("score", 99)(Each comparison accepts a String, a Number, or a Boolean — so for a
boolean column write eq("done", false), not eq("done", "false").)
Pattern matching & membership
like/ilike do SQL text matching with % wildcards (ilike is the
case-insensitive version), and `in` checks whether a column’s value is one
of a list:
like("name", "%ada%"); ilike("name", "%ADA%")
likeAllOf("tag", listOf("a", "b")); ilikeAnyOf("tag", listOf("x", "y"))
`is`("deleted_at", null) // IS NULL
`in`("status", listOf("active", "pending"))
match(mapOf("org" to "1", "role" to "member"))Arrays, ranges & full-text
For Postgres-specific column types — array columns, range columns, and full-text search over text:
contains("tags", "{kotlin,kmp}"); containedBy("tags", "{a,b,c}"); overlaps("tags", "{x,y}")
rangeGt("period", "[2024-01-01,2024-02-01)") // also rangeGte/Lt/Lte/Adj + strictlyLeft/Right…
textSearch("body", "kotlin & multiplatform", type = TextSearchType.Websearch)Logical & raw
Group conditions explicitly with or / and, negate a group with not, or drop
down to filter to use any PostgREST operator by name when the DSL doesn’t have a
dedicated helper:
or { eq("status", "active"); eq("status", "pending") }
and { gte("age", 18); lt("age", 65) }
not { eq("archived", "true") }
filter("price", "gte", "10") // arbitrary PostgREST operatorOrdering & pagination
Sort the results and fetch them in pages — useful for infinite-scroll lists or keeping responses small:
order("created_at", ascending = false, nullsFirst = false)
limit(25)
range(0, 24) // rows 0..24 inclusive (offset pagination)RPC (stored functions)
RPC (“remote procedure call”) means calling a database function by name —
a stored function you’ve written in your database that runs custom SQL or logic
server-side. Use it when a single CRUD call isn’t enough: aggregations, multi-step
transactions, or anything you’d rather keep in the database. Functions that change
data are called with POST and a JSON body; read-only functions can be called
with GET.
@Serializable data class StatsReq(val user_id: String)
@Serializable data class Stats(val total: Int)
// POST with a JSON body:
val stats: SupabaseResult<Stats> =
database.rpcTyped<StatsReq, Stats>(function = "get_dashboard_stats", params = StatsReq("123"))
// GET (read-only function):
val rows = database.rpcGetListTyped<Todo>(function = "list_todos", queryParams = listOf("limit" to "10"))RPC mirrors the read API: rpcTyped, rpcListTyped, rpcSingleTyped,
rpcMaybeSingleTyped, rpcUnit, rpcCsv, rpcHead — each with a matching
rpcGet* GET form. Pass a @Serializable request object or a raw JSON string.
Options reference
The enums you’ll pass to the options mentioned above (count, returning,
upsertResolution, and friends), with their accepted values:
| Enum | Values |
|---|---|
CountOption | EXACT, PLANNED, ESTIMATED |
ReturnOption | MINIMAL, REPRESENTATION |
UpsertResolution | MERGE_DUPLICATES, IGNORE_DUPLICATES |
TextSearchType | Plain, Phrase, Websearch |
ExplainFormat | TEXT, JSON, XML, YAML |
Reads default to retry = true (transient failures are retried). The raw
interface methods (select / insert / update / delete / rpc /
rpcGet) accept a headers: Map<String, String> for per-request overrides.