Skip to content
Open beta — everything’s free right now, and your rate is locked when it ends.

Writing records over the API: create, update, upsert, delete

Batches of up to 100, all-or-nothing — including what a failed batch leaves behind (nothing). Upsert semantics spelled out, delete-to-trash, and the exact value shape for every tricky field type.

Updated August 2026

Four verbs on one endpoint, /api/tables/:id/records: POST creates, PATCH updates by id, PUT upserts, DELETE trashes. Each call carries up to 100 records and is all-or-nothing — one transaction, so a batch that fails on record 97 leaves records 1 through 96 unwritten too.

Create, and what a failed batch leaves

POST takes {"records":[{"fields":{…}},…]}. Send three plants where the third names a select choice that doesn't exist:

curl -X POST "https://tabladb.com/api/tables/tbl_jukzu86nxtcj/records" \
  -H "Authorization: Bearer key_xxxx.your-secret" \
  -H "Content-Type: application/json" \
  -d '{"records":[{"fields":{"Name":"Spider plant","Price":14}},
       {"fields":{"Name":"Jade plant","Price":16}},
       {"fields":{"Name":"Calathea","Price":34,"Light":"Shade"}}]}'
{"error":{"code":"bad_request","message":"\"Shade\" is not a valid choice for field \"Light\"."}}

The two valid rows did not land. Filtering for them right after returns {"records":[]} — captured, not assumed. Fix the batch, resend the whole thing; there is no partial state to reconcile.

Update by id

PATCH takes the same shape plus each record's id. Only the fields you send change. An optional if_updated_at per record makes the write refuse with a 409 if the record changed since you loaded it — the conflict shape is in errors and limits.

Upsert: match, then update or create

PUT adds merge_on — the field or fields that identify a record:

curl -X PUT "https://tabladb.com/api/tables/tbl_jukzu86nxtcj/records" \
  -H "Authorization: Bearer key_xxxx.your-secret" \
  -H "Content-Type: application/json" \
  -d '{"merge_on":["Name"],"records":[
       {"fields":{"Name":"Fiddle-leaf fig","In stock":9}},
       {"fields":{"Name":"Calathea orbifolia","Price":34,"Light":"Low light","In stock":5}}]}'

Each incoming record is matched on its merge_on values against the table: exactly one match is updated, no match is created. The response says which was which, twice over — on each record, and grouped as ids. Here the fig existed and the calathea didn't:

{"records": [
   {"id": "rec_utf767j36rpj", "created": false, "fields": {"Name": "Fiddle-leaf fig", "…": "…"}},
   {"id": "rec_janiuq4ei9up", "created": true,  "fields": {"Name": "Calathea orbifolia", "…": "…"}}],
 "created_records": ["rec_janiuq4ei9up"],
 "updated_records": ["rec_utf767j36rpj"]}

Reach for created on the record when you're looping — an automation tool holding one record can read its own flag, where finding that same id in a sibling array means a search it usually can't express. The two arrays are the same fact for anything that would rather have it grouped.

One asymmetry worth knowing: an attachment cell on a record the upsert matched is appended to, not replaced. An upsert is usually a repeated sync, and one that deleted the files already on a record it merely re-saw would be the more expensive surprise. It's the only place upsert's cell semantics differ from PATCH's — to clear or shorten an attachment cell, use PATCH.

The honest edges, all refused with nothing written:

  • Two existing records already share the key: 2 records already have Name="Fiddle-leaf fig", so this can't identify one. The key failed at its one job, so no guess is made.
  • Two records in the batch share a key: Two records in this batch have the same Name="Calathea orbifolia".
  • A record omits a merge_on field entirely — a missing key can't identify anything.

merge_on takes any single-valued stored field (text, number, date, checkbox, single select — a member field counts as text). A link, a multi-select, an attachment or anything computed can't identify a record and is refused by name.

Delete is trash

curl -X DELETE "https://tabladb.com/api/tables/tbl_jukzu86nxtcj/records" \
  -H "Authorization: Bearer key_xxxx.your-secret" \
  -H "Content-Type: application/json" \
  -d '{"records":["rec_gjb32pptrttb"]}'
{"records":[{"id":"rec_gjb32pptrttb","deleted":true}]}

Deleted records sit in the trash for 7 days — POST /api/tables/:id/trash/restore brings them back, links and attachments included. Clients that can't send a DELETE body may use ?records=rec_a,rec_b instead.

Cell shapes for the tricky types

Every shape below was verified with a real write and read-back.

  • Linked records — write a list of rec_… ids: "Customer": ["rec_ugnu6fgyhpsw"]. It reads back enriched: "Customer": [{"id": "rec_ugnu6fgyhpsw", "display": "Dina Aziz"}] — and a PATCH may send that object form back unchanged. A link field set to a single record enforces it: two ids answer Field "Customer" links a single record; got 2.
  • Selects — write the choice name ("Status": "New") or its id; either resolves, the id is what's stored, names come back on reads (cell_format=ids for ids).
  • Attachments — a list whose items may be an att_… id, an https:// URL, or {"url": "…", "filename": "…"}, mixed freely and kept in the order you send. A URL is fetched by Tabla during the write, so a file you have a link to needs no upload call and no second request: {"Photos": ["https://example.com/fig.png"]} works on create, update and upsert alike. If you have raw bytes instead of a link, upload them first — POST /api/tables/:id/attachments (multipart: file, field_id, record_id, and the record must already exist) returns {"id": "att_s86mde8pwzay", "filename": "fig.png", "size_bytes": 69, …} — then write that id into the cell. An id you didn't upload is refused: Unknown attachment id(s): att_000000000000. Upload the file first, or send its URL and Tabla will fetch it. The fetch is anonymous, so the link has to be publicly reachable; 20 URLs per request, 30s each and 60s in total; and if one of them fails the whole batch fails and every file already fetched for it is deleted again. One thing to know before you point a retry at it: fetching is not idempotent, so a call that succeeds twice stores the file twice. A retry after a failure is safe — a failed batch keeps nothing.
  • Member — write the person's email: "Assigned to": "omar@sakkaraplants.com". The API stores the address as sent and does not check it against the member list; an address that belongs to no member shows in the grid as the bare address, with no name or photo to resolve to.
  • Dates"2026-08-12", or a full timestamp for date-and-time fields. Checkboxtrue/false. Duration — seconds, or "1:30".
  • Formula, lookup, rollup, button — computed, not written: "To collect" is a formula — it's computed automatically and can't be written to directly.

null clears any cell. The full field-by-field reference is at /docs/api; when a mapping must survive renames, key everything by id (names and ids).

Tabla is the database we build these on.

A no-code database with real Postgres underneath: every feature on every plan, a million records per database, and your whole database back out in one file, any day.

More guides