batch
Apply many creates, updates and deletes in one atomic call, and match the results back to the requests
Call
import json
import sys
sys.path.insert(0, "src") # or PYTHONPATH=src
from sg_groundtruth.client import FPT # adds the bearer token and the /api/v1 prefix
from sg_groundtruth.env import load
c = FPT.from_env(load(".")) # FPT_API_SITE_URL, FPT_API_SCRIPT_NAME, FPT_API_API_KEY
PROJECT_ID = 1234 # the caller supplies this
def run(requests_):
"""One atomic call. Plain application/json; the vendor type _search needs 415s here."""
r = c.post("/entity/_batch", json={"requests": requests_})
if not r.ok:
raise SystemExit(json.dumps(r.json()["errors"], indent=2)) # nothing was applied
return r.json()["data"]
def row_id(row):
"""A create or update row nests the record under `data`; a delete row is flat."""
return row.get("data", row)["id"]
# 1. A batch cannot reference an id it creates, so the rows the rest of the batch points at
# go in a call of their own.
shot_id = row_id(run([{"request_type": "create", "entity": "Shot",
"data": {"project": {"type": "Project", "id": PROJECT_ID},
"code": "sh010", "description": "batch 1"}}])[0])
# 2. Two creates and an update of another entity type, in one call. `entity` is the singular
# schema name and the id key is `record_id`.
reqs = [
{"request_type": "create", "entity": "Version",
"data": {"project": {"type": "Project", "id": PROJECT_ID}, "code": "v001",
"entity": {"type": "Shot", "id": shot_id}, "sg_status_list": "rev"}},
{"request_type": "create", "entity": "Version",
"data": {"project": {"type": "Project", "id": PROJECT_ID}, "code": "v002",
"entity": {"type": "Shot", "id": shot_id}, "sg_status_list": "rev"}},
{"request_type": "update", "entity": "Shot", "record_id": shot_id,
"data": {"description": "batch 2"}},
]
out = run(reqs)
# 3. Results are one row per request, in request order. Pair them by position: two rows can share
# a code, and matching on one would mislink them.
ids = [row_id(row) for row in out]
for req, i in zip(reqs, ids):
print(req["request_type"], req["entity"], i)
# 4. Delete takes record_id and no data. Mixed types are fine in one call.
run([{"request_type": "delete", "entity": "Version", "record_id": ids[0]},
{"request_type": "delete", "entity": "Version", "record_id": ids[1]},
{"request_type": "delete", "entity": "Shot", "record_id": shot_id}])
Response
1. batch 1 200 [{"data": {"type": "Shot", "id": 7557, "attributes": {...}, "links": {...}}}]
2. batch 2 200, three rows, in request order
row 0 create keys ['data'] Version 29926 code 'v001'
row 1 create keys ['data'] Version 29927 code 'v002'
row 2 update keys ['data', 'links', 'status'] Shot 7557 description 'batch 2'
row 0 in full, trimmed:
{"data": {"type": "Version", "id": 29926,
"attributes": {"code": "v001", "sg_status_list": "rev", "cached_display_name": "v001",
"created_at": "2026-09-02 19:07:26 UTC", "viewed_by_current_user": "unread",
"updated_at": "2026-09-02 19:07:26 UTC", "open_notes_count": 0,
"sg_version_type": "Type A"},
"relationships": {"entity": {...}, "project": {...}, "tasks": {"data": []}, ...},
"links": {"self": "/api/v1/entity/versions/29926"}}}
3. step 3 prints
create Version 29926
create Version 29927
update Shot 7557
4. delete 200, one flat row per request. One of them verbatim:
{"request_type": "delete", "type": "Version", "id": 29941,
"uuid": "906c4522-a701-11f1-b496-0a58a9feac02", "did_delete": true}
Notes
A batch cannot use an id it creates. Every way of pointing request 1 at request 0's row was rejected, and the failure is the whole batch, so nothing at all lands.
entityvalue sentresult {"type": "Shot", "id": "$0"}400 Invalid field value, update failed [5 - Update failed for [Version.entity]: Value is not legal.]{"type": "Shot", "id": -1}400, the same {"type": "Shot", "id": "0"}400, the same {"type": "Shot", "id": "u1"}, request 0 sent with"uuid": "u1"400, the same {"type": "Shot", "uuid": "u1"}, request 0 sent with"uuid": "u1"400 Invalid field value, update failed [5 - Update failed for [Version.entity]: Invalid statement.]The
uuida delete row returns is generated per request and is not an input. Build a dependent graph as one batch per level: create the parents, read their ids out of the response, substitute, then send the children. Steps 1 and 2 above are that sequence.Results are in request order, one row per request, interleaved by neither id nor type. A batch of
[update 29926, create, update 29927, create, update Shot 7557]answered in exactly that order, sozip(requests, response["data"])is correct and no key matching is needed.Two creates sending the same
codecame back as two rows distinguished only by position and by the new ids, 29930 and 29931.One failing request rolls back every other one. Each round below sent a good create, one bad request, and an update of an existing row whose
descriptionreadbefore:the bad request status after it updaterecord_id999999999404 Entity of type [Version] with id=999999999 does not exist.0 rows created, descriptionstillbeforedeleterecord_id999999999404, the same 0 rows created, descriptionstillbeforecreatewithsg_not_a_field400 Invalid field value, update failed [2 - Invalid field name: field [Version.sg_not_a_field] does not exist or user does not have access permission.]0 rows created, descriptionstillbeforecreatewithsg_status_list: not_a_status400 Invalid field value, update failed [5 - Update failed for [Version.sg_status_list]: 'not_a_status' is not a valid status. Valid statuses: 'na', 'rev', 'vwd', 'apr', 'custom', 'fin', 'ip', 'clsd', 'cmpt', 'cfrm', 'pndad', 'pndl', 'pndvs', 'part', 'pass', 'pndng'.]0 rows created, descriptionstillbeforeThe rollback is the reason to use the endpoint. A timeout is not covered by it: see the size note.
A batch create skips the validation a single create applies, and the row it makes is unreadable.
POST /entity/versionswith noprojectis 400API create() missing 'project' attribute: {"code" => "v001"}. The same create inside a batch answered 200 withid29932 and a create row holding noprojectrelationship.GET /entity/versions/29932then answered 404Version: 29932 not found, and a site-widePOST /entity/versions/_searchon itscodereturned 0 rows.DELETE /entity/versions/29932answered 204, so the row exists and only the id from the create response can reach it.Validate a batch payload yourself; a 200 is not proof the row is addressable. A link to an id that does not exist is rejected on both paths, 400
Update failed for [Version.entity]: Value is not legal.Size. No cap was found. A
requestsarray of 5001 was validated in full, answering onedata hash containing field/value pairs is required for the given requestper element.On the probed site a committing batch of 200 answered in 11.7s, 500 in 31.0s and 1001 in 47.7s on one run and not at all on another, where the client gave up at its own 60s read timeout. All 1001 rows had committed anyway.
A read timeout tells you nothing about what landed, and there is no request id to ask about, so keep a batch inside the response window, around 200 requests, and make each chunk re-runnable by reading back on
codebefore resending.The contract, one 400 at a time. Every rejection below names what it wanted.
sent result Content-Type: application/vnd+shotgun.api3_array+json415 Unsupported Content-Type 'application/vnd+shotgun.api3_array+json',{"content_type": "Content-Type must be one of: 'application/json'."}a top-level array 400 Invalid JSON body. Expected Hash but received Array.{"entity": "Version"}400 Request Parameters invalid.{"requests": ["requests is missing"]}{"requests": []}200 {"data": []}a request with no entity400 {"requests": {"0": {"entity": ["entity is missing"]}}}a createwith nodata400 {"data": ["data hash containing field/value pairs is required for the given request"]}"request_type": "read"400 {"requests": {"0": {"request_type": ["request_type must be one of: create, update, delete"]}}}"entity": "versions", the URL slug400 Invalid entity type: entity type [] does not exist.deletewith norecord_id, or withentity_id404 Entity of type [Version] with id=0 does not exist.deletein a batch and theDELETEverb do the same thing and report it differently.body after it batch delete200 {"request_type": "delete", "type": "Version", "id": N, "uuid": "...", "did_delete": true}GETthat id 404sDELETE /entity/versions/N204, 0 bytes GETthat id 404sDeleting an already deleted id inside a batch is 404
Entity of type [Version] with id=N does not exist.and takes the rest of the batch down with it, so a delete pass is not idempotent.Response shape differs by
request_type: a create row is the thin create subset, an update row is the whole record wrapped withlinksandstatus, a delete row is flat (probe 024 for the field-level table).?fieldson/entity/_batchis accepted and ignored, as on every other write (probe 024), and no row resolves a dotted path, so re-read for those.