Odoo CRUD via the API: create, write, unlink
How to create, update, and delete Odoo records over the API: the params shape for create, write, and unlink, batch writes, and the x2many command tuples.
ODXProxy Team · Jul 4, 2026 · 7 min read

Reading data out of Odoo is the easy half. The moment you need to create, update, or delete
records over the API, three actions come into play — create, write, and unlink — and each has
its own params shape that's easy to get subtly wrong. This guide covers Odoo CRUD via the API
end to end: the exact request shape for writing data, how to batch, how to touch relational fields
with command tuples, and why a write that "succeeded" can still have failed.
The three write actions at a glance
create, write, and unlink are three of the nine actions ODXProxy exposes directly (alongside
search, read, search_count, search_read, fields_get, and call_method), so you call each
by name without routing through call_method. The difference between them is entirely in what goes
into params:
| Action | params shape | Returns |
|---|---|---|
create | [values] — one dict (or a list of dicts to batch) | new id (or list of ids) |
write | [[ids], values] — the ids to update, then the changes | true |
unlink | [[ids]] — the ids to delete | true |
Everything below is just those three shapes filled in. As always, params is a JSON array and
keyword is a JSON object (default {}); the domain-and-fields split you use for reads doesn't
apply here.
Creating a record
create takes a single positional argument: a dict of field values. It returns the integer id of
the new record. Here's a new company contact:
{
"id": "create-1",
"action": "create",
"model_id": "res.partner",
"params": [{ "name": "Gemini Furniture", "email": "info@gemini.example", "is_company": true }],
"keyword": {},
"odoo_instance": {
"url": "https://erp.example.com",
"db": "prod",
"user_id": 2,
"api_key": "<the Odoo user API key>"
}
}The values dict is nested one level deep because it's the first (and only) positional argument inside
params. The response is a plain 200 with the new id in result:
{ "jsonrpc": "2.0", "id": "create-1", "result": 42 }Creating many at once
Pass a list of dicts as that first positional argument and Odoo creates them in one call,
returning a list of ids. Note the extra nesting — params is the argument list, and its single
element is now a list of records:
"params": [[
{ "name": "Gemini Furniture", "is_company": true },
{ "name": "Azure Interior", "is_company": true }
]]{ "jsonrpc": "2.0", "id": "create-2", "result": [42, 43] }Batching is dramatically faster than a loop of single creates — one round trip, one transaction — so prefer it whenever you're importing more than a handful of records.
Updating records with write
write takes two positional arguments: the list of ids to update, then a dict of the fields to
change. It applies the same changes to every id in the list and returns true:
{
"id": "write-1",
"action": "write",
"model_id": "res.partner",
"params": [[42, 43], { "customer_rank": 1, "comment": "Imported 2026-07" }],
"keyword": {}
}{ "jsonrpc": "2.0", "id": "write-1", "result": true }A couple of things that surprise people:
writereturnstrue, not the record. It confirms the update landed; it does not echo the new state. Follow up with asearch_readif you need to see the result.- It's all-or-nothing per call. Every id in the list gets the identical
valuesdict. To apply different values to different records, make separatewritecalls (or batch them as separate requests).
Usually you don't have the ids sitting around — you find them first. Run a
search to turn a domain into a list of ids, then feed those ids
straight into write or unlink.
Deleting records with unlink
unlink is the simplest: one positional argument, the list of ids to delete. It returns true:
{
"id": "unlink-1",
"action": "unlink",
"model_id": "res.partner",
"params": [[42, 43]],
"keyword": {}
}unlink is a hard delete, not an archive. If you only want to hide a record while keeping its history and references intact, set its active field to false with a write instead — that archives it. Reach for unlink only when the record should truly cease to exist, and expect a logic error if other records still reference it.Setting relational fields: the command tuples
Plain scalar fields take plain values. But one2many and many2many fields (tags, order lines,
child contacts) are edited with Odoo's special command tuples — little [command, id, values]
arrays that tell Odoo how to change the relationship rather than replacing it wholesale. These are
the ones you'll use:
| Command | Meaning |
|---|---|
[0, 0, {values}] | create a new linked record from values |
[1, id, {values}] | update the linked record id with values |
[2, id, 0] | delete the linked record id entirely |
[3, id, 0] | unlink id (break the link, keep the record) |
[4, id, 0] | link an existing record id |
[5, 0, 0] | unlink all currently linked records |
[6, 0, [ids]] | replace the whole set with exactly [ids] |
So attaching two existing tags to a partner while creating it:
"params": [{
"name": "Gemini Furniture",
"category_id": [[6, 0, [3, 7]]]
}]The [6, 0, [3, 7]] says "make the tag set exactly records 3 and 7." To add one new tag on an
existing partner without disturbing the others, use [4, id, 0] inside a write:
"params": [[42], { "category_id": [[4, 9, 0]] }]parent_id or country_id) are the exception — they take a bare id as the value (for example set parent_id to 5), or false to clear. The command tuples are only for the x2many "collection" fields.A write can return HTTP 200 and still have failed
This is the trap that catches every Odoo integration. Validation errors, required-field violations,
and access-rights denials come back from Odoo as logic errors with an HTTP 200 and a populated
error object — not a non-200 status. A create that violates a constraint looks like this:
{
"jsonrpc": "2.0",
"id": "create-1",
"error": {
"code": 200,
"message": "The email address is not valid.",
"data": { "name": "odoo.exceptions.ValidationError" }
}
}That error.code of 200 is Odoo's own code for a server-side exception — it has nothing to do
with the HTTP status. So on every write you must check twice: first the HTTP status (non-200 is a
proxy-layer failure), then, even on a 200, whether error is set before you trust result. In
Python:
resp = requests.post(PROXY_URL, headers={"x-api-key": PROXY_API_KEY}, json=payload, timeout=20)
resp.raise_for_status() # step 1: proxy-level failures are non-200
body = resp.json()
if body.get("error"): # step 2: a 200 can still carry an Odoo error
err = body["error"]
raise RuntimeError(f"Odoo error {err['code']}: {err['message']}")
new_id = body["result"]The full two-layer model, with a reusable handler and every proxy error code, is in Odoo API error handling.
Where to go next
Writing to Odoo is three actions and their params shapes: create with a values dict, write with
[[ids], values], unlink with [[ids]] — plus the command tuples for relational fields and the
mandatory two-step response check.
- Get the ids you need to
writeorunlinkwith the Odoo search_read example. - Sharpen the domains that find those records in Odoo domain filters.
- See the full request/response contract in the API reference and the action list in the actions reference.
- Skip the envelope boilerplate with the Python SDK.