Modifying Data
Writes use POST (insert), PATCH (update), and DELETE. By default writes
return no body — add Prefer: return=representation to get the affected rows
back.
Throughout: BASE="http://localhost:39001/v1/rest/<project_id>".
Insert
# Single row, return itcurl -X POST "$BASE/posts" \ -H "apikey: $ANON_KEY" -H "Content-Type: application/json" \ -H "Prefer: return=representation" \ -d '{"title":"Hello","status":"draft"}'
# Bulk insert — pass an arraycurl -X POST "$BASE/posts" \ -H "apikey: $ANON_KEY" -H "Content-Type: application/json" \ -d '[{"title":"A"},{"title":"B"}]'// returns inserted rows when you chain .select()const { data } = await db.from('posts').insert({ title: 'Hello' }).select()await db.from('posts').insert([{ title: 'A' }, { title: 'B' }]) // bulk// chain .single() to get the inserted row as a single object (201 Created)const { data: row } = await db.from('posts').insert({ title: 'Hi' }).select().single()db.table("posts").insert({"title": "Hello"}).execute()db.table("posts").insert([{"title": "A"}, {"title": "B"}]).execute()await db.from('posts').insert({'title': 'Hello'});await db.from('posts').insert([{'title': 'A'}, {'title': 'B'}]);.insert(row).select().single()returns the inserted row as a single object (not a one-element array), with201 Created. If the write affects 0 or more than 1 row, it returns406(PGRST116) — the same.single()contract as reads. The same holds for.update(…).select().single()and.delete().select().single().- Bulk inserts of heterogeneous or sparse rows work: the SDK pins the column
set via a
?columns=hint, and any column a given row omits takes itsDEFAULT(orNULL).
Inserts must satisfy the table’s RLS WITH CHECK expression on
INSERT/ALL policies, or they’re rejected. See
Row Level Security.
Update
Updates require a filter — the same column=op.value syntax as reads — so you
never accidentally update the whole table.
curl -X PATCH "$BASE/posts?id=eq.$ID" \ -H "apikey: $ANON_KEY" -H "Content-Type: application/json" \ -H "Prefer: return=representation" \ -d '{"status":"published","published_at":"2026-06-04T10:00:00Z"}'const { data } = await db.from('posts') .update({ status: 'published' }) .eq('id', id) .select()db.table("posts").update({"status": "published"}).eq("id", id).execute()await db.from('posts').update({'status': 'published'}).eq('id', id);A
PATCHwith no filter would attempt to update every row. The engine requires a filter to guard against this; always scope your update.
Flat filters + logical groups. Updates accept flat
column=op.valueconditions (combined with&, whichANDs them together) and the logical-group filtersor=(…)/and=(…). All parts areAND-combined into the finalWHERE. APATCHmust still carry at least one flat filter (the whole-table guard); a logical group is applied in addition to it and can only narrow the affected rows, e.g.PATCH /posts?org_id=eq.$ORG&or=(status.eq.draft,status.eq.review)updates only the org’s draft-or-review rows.
Upsert
Insert-or-update on a conflict target. Set the conflict column and the resolution:
curl -X POST "$BASE/posts?on_conflict=slug" \ -H "apikey: $ANON_KEY" -H "Content-Type: application/json" \ -H "Prefer: resolution=merge-duplicates,return=representation" \ -d '{"slug":"hello","title":"Hello (updated)"}'Prefer: resolution=merge-duplicates updates the existing row;
resolution=ignore-duplicates leaves it untouched.
await db.from('posts') .upsert({ slug: 'hello', title: 'Hello (updated)' }, { onConflict: 'slug' }) .select()db.table("posts").upsert({"slug": "hello", "title": "Hello"}, on_conflict="slug").execute()Upsert on the primary key (no on_conflict)
The canonical supabase-js upsert omits onConflict and conflicts on the
table’s primary key:
// conflicts on the PK — no onConflict neededawait db.from('posts').upsert({ id: 1, title: 'Hello (updated)' })curl -X POST "$BASE/posts" \ -H "apikey: $ANON_KEY" -H "Content-Type: application/json" \ -H "Prefer: resolution=merge-duplicates,return=representation" \ -d '{"id":1,"title":"Hello (updated)"}'When resolution=merge-duplicates carries no on_conflict target, the
engine introspects the table’s primary key (all columns, composite keys
included) and upserts on it. A table with no primary key returns 400
asking you to pass an explicit on_conflict target — it never silently falls
back to a plain insert that would 409 on conflict.
Delete
Also filter-scoped:
curl -X DELETE "$BASE/posts?id=eq.$ID" -H "apikey: $SERVICE_KEY"
# delete manycurl -X DELETE "$BASE/posts?status=eq.spam" -H "apikey: $SERVICE_KEY"await db.from('posts').delete().eq('id', id)db.table("posts").delete().eq("id", id).execute()await db.from('posts').delete().eq('id', id);Deletes are subject to RLS DELETE/ALL policies — a user can only delete rows
their policy permits. Use service_role only in trusted server code.
Flat filters + logical groups. Like updates, deletes accept flat
column=op.valueconditions (&-combined asAND) and the logical-group filtersor=(…)/and=(…), allAND-combined. ADELETEmust still carry at least one flat filter (the whole-table guard); the logical group only narrows the deleted set, e.g.DELETE /posts?org_id=eq.$ORG&or=(status.eq.spam,status.eq.deleted).
Controlling the response
| Header | Effect |
|---|---|
Prefer: return=representation | return the affected rows |
Prefer: return=minimal | return nothing (default) |
Prefer: count=exact | include the affected-row count as Content-Range: */N (works on INSERT/UPDATE/DELETE) |
Accept: application/vnd.pgrst.object+json | return a single object, not an array |
Transactions and atomicity
Each REST call runs in its own transaction. For multi-statement atomic work (insert into two tables, conditional logic), wrap it in a Postgres function and call it via RPC — the whole function executes in one transaction.
Validation
- Column types are enforced by Postgres; a bad value yields
400with a hint. NOT NULL,CHECK, unique, and foreign-key constraints all apply; violations return409(conflict) or400with the constraint detail.- RLS
WITH CHECKruns on insert/update — a write that would land outside a user’s allowed set is rejected.
Next: RPC & Stored Procedures.