Skip to content

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

Terminal window
# Single row, return it
curl -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 array
curl -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), with 201 Created. If the write affects 0 or more than 1 row, it returns 406 (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 its DEFAULT (or NULL).

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.

Terminal window
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 PATCH with 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.value conditions (combined with &, which ANDs them together) and the logical-group filters or=(…) / and=(…). All parts are AND-combined into the final WHERE. A PATCH must 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:

Terminal window
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 needed
await db.from('posts').upsert({ id: 1, title: 'Hello (updated)' })
Terminal window
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:

Terminal window
curl -X DELETE "$BASE/posts?id=eq.$ID" -H "apikey: $SERVICE_KEY"
# delete many
curl -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.value conditions (&-combined as AND) and the logical-group filters or=(…) / and=(…), all AND-combined. A DELETE must 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

HeaderEffect
Prefer: return=representationreturn the affected rows
Prefer: return=minimalreturn nothing (default)
Prefer: count=exactinclude the affected-row count as Content-Range: */N (works on INSERT/UPDATE/DELETE)
Accept: application/vnd.pgrst.object+jsonreturn 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 400 with a hint.
  • NOT NULL, CHECK, unique, and foreign-key constraints all apply; violations return 409 (conflict) or 400 with the constraint detail.
  • RLS WITH CHECK runs on insert/update — a write that would land outside a user’s allowed set is rejected.

Next: RPC & Stored Procedures.