🎉 Native Google & Apple sign-in is here → read the guide
Database

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:

MethodReturnsUse
selectTyped<T>(table, columns, …) { filters }List<T>the common case
selectSingleTyped<T>(…) { … }Texpects 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 operator

Ordering & 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:

EnumValues
CountOptionEXACT, PLANNED, ESTIMATED
ReturnOptionMINIMAL, REPRESENTATION
UpsertResolutionMERGE_DUPLICATES, IGNORE_DUPLICATES
TextSearchTypePlain, Phrase, Websearch
ExplainFormatTEXT, 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.