← Blog
odooormcrudjson-rpc

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

Odoo CRUD via the API: create, write, unlink — ODXProxy blog cover

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:

Actionparams shapeReturns
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 changestrue
unlink[[ids]] — the ids to deletetrue

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:

  • write returns true, not the record. It confirms the update landed; it does not echo the new state. Follow up with a search_read if you need to see the result.
  • It's all-or-nothing per call. Every id in the list gets the identical values dict. To apply different values to different records, make separate write calls (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.

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:

CommandMeaning
[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]] }]
Many2one fields (a single relation like 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.