Odoo search_read over XML-RPC vs JSON-RPC
The same Odoo search_read call, shown over both XML-RPC and JSON-RPC 2.0 — with runnable examples, the identical domain/fields/limit arguments, and which transport to pick.
ODXProxy Team · Jul 12, 2026 · 8 min read

Search for how to run search_read in Odoo and you'll find two camps of answers: one reaching for
XML-RPC (xmlrpc.client, /xmlrpc/2/object) and one reaching for JSON-RPC. That split causes a
lot of confusion, because it makes search_read look like two different features. It isn't. search_read
is a single ORM method, and XML-RPC and JSON-RPC are just two ways to serialize the same call on the
wire. This guide shows the identical query over both transports — same model, same domain, same
fields and limit — so you can read either style of example and pick the transport that fits your
stack.
search_read is one method, two encodings
search_read combines a search (find record ids matching a domain) and a read (fetch fields for
those ids) into one round trip, so you avoid the two-call pattern of searching for ids and then reading
them. Whichever transport you use, you are calling Odoo's one universal server method, execute_kw, with
the same five inputs:
- the database name,
- your user id (
uid, an integer), - an API key for that user,
- the model and the method name (
search_read), - the positional args (a domain filter) and keyword args (
fields,limit,offset,order).
XML-RPC and JSON-RPC don't change any of that. They change only how those arguments are packed for
transport — XML <struct>/<member> elements versus a JSON object. Once you internalize that, every
search_read example on the internet reads the same way regardless of which camp wrote it.
search_read over XML-RPC
XML-RPC is the older transport and still the one most legacy tutorials use. In Python it needs no extra
dependency — xmlrpc.client is in the standard library. You authenticate against /xmlrpc/2/common to
turn a login + API key into a uid, then call execute_kw on /xmlrpc/2/object:
import xmlrpc.client
URL = "https://erp.example.com"
DB = "prod"
USERNAME = "svc-integration"
API_KEY = "<the Odoo user API key>" # an API key works in place of the password (Odoo 14+)
common = xmlrpc.client.ServerProxy(f"{URL}/xmlrpc/2/common")
uid = common.authenticate(DB, USERNAME, API_KEY, {})
models = xmlrpc.client.ServerProxy(f"{URL}/xmlrpc/2/object")
partners = models.execute_kw(
DB, uid, API_KEY,
"res.partner", "search_read",
[[["is_company", "=", True]]], # positional args: the domain
{"fields": ["name", "email"], "limit": 5}, # keyword args
)
print(partners)The two arguments that carry the query are the important part: the positional list holds the domain
(wrapped one level deep because the domain is itself the first positional argument), and the keyword
dict holds fields, limit, and friends. Hold that shape in your head — it maps one-to-one onto the
JSON-RPC version below.
uid from a login string. Note the uid is a numeric database id, not the username — you pass it (not the login) to every subsequent execute_kw call.search_read over JSON-RPC
For anything new, JSON-RPC 2.0 is the cleaner transport: the payload is plain JSON your language
already understands, it's trivial to inspect in Postman or a log line, and every call shares one
envelope. Odoo ships a native /jsonrpc endpoint, and ODXProxy — a reverse proxy that sits in front
of one or more Odoo instances — exposes a single unified JSON-RPC endpoint, POST /api/odoo/execute,
that forwards to the right instance for you.
Here is the exact same query — res.partner, is_company = true, name/email, limit 5 — through the
proxy:
curl -X POST https://your-proxy.example.com/api/odoo/execute \
-H "Content-Type: application/json" \
-H "x-api-key: $ODX_PROXY_KEY" \
-d '{
"id": "search-read-1",
"action": "search_read",
"model_id": "res.partner",
"params": [[["is_company", "=", true]]],
"keyword": { "fields": ["name", "email"], "limit": 5 },
"odoo_instance": {
"url": "https://erp.example.com",
"db": "prod",
"user_id": 2,
"api_key": "<the Odoo user API key>"
}
}'Line the two up and the correspondence is exact:
| Concept | XML-RPC | JSON-RPC (proxy) |
|---|---|---|
| Method | execute_kw(..., "search_read", ...) | "action": "search_read" |
| Model | "res.partner" positional arg | "model_id": "res.partner" |
| Domain | [[["is_company", "=", True]]] | "params": [[["is_company", "=", true]]] |
| Fields / limit | {"fields": [...], "limit": 5} | "keyword": { "fields": [...], "limit": 5 } |
params is always a JSON array (the positional args, default []) and keyword is always a JSON
object (the kwargs, default {}). That's the whole translation. On success you get HTTP 200 and a
JSON-RPC envelope whose result holds the rows:
{
"jsonrpc": "2.0",
"id": "search-read-1",
"result": [
{ "id": 9, "name": "Gemini Furniture", "email": "info@gemini.example" },
{ "id": 14, "name": "Azure Interior", "email": "hello@azure.example" }
]
}If you'd rather hit Odoo's built-in JSON-RPC endpoint directly, the same call is a POST to /jsonrpc
with method: "call" and params.service: "object", params.method: "execute_kw", and the same
[db, uid, api_key, model, "search_read", [domain], kwargs] argument list you saw in the XML-RPC code.
Different envelope, identical arguments.
Two secrets when a proxy is in front
Going through a proxy adds exactly one thing you must get right, and it's the most common cause of a
mysterious 401: there are two different keys.
x-api-key— the proxy's key. It authenticates your app to the proxy and travels as an HTTP header. It is the same for every Odoo instance behind that proxy.odoo_instance.api_key— the Odoo user's key. It authenticates the actual ERP call and is sent per request inside the body.
They are never the same value. Talking to Odoo's XML-RPC or /jsonrpc endpoint directly, only the second
one exists; the moment a gateway is in front, keep the two straight. The credentials themselves — the
uid and the API key — are set up in how to authenticate to the Odoo API.
Shaping the query: domain, fields, limit, order
Everything that makes search_read useful lives in those two argument slots, and it's identical across
transports:
fields— the columns to return. Always pass it; omittingfieldsmakes Odoo return every field on the model, which is slow and heavy.limitandoffset— page through large result sets instead of pulling everything at once.order— a SQL-style sort string, e.g."name asc"or"create_date desc".- the domain — the filter itself, a list of triples like
["is_company", "=", true]combined with"&","|", and"!"operators.
For example, the twenty most recently created companies with an email set, over JSON-RPC:
{
"id": "search-read-2",
"action": "search_read",
"model_id": "res.partner",
"params": [[["is_company", "=", true], ["email", "!=", false]]],
"keyword": { "fields": ["name", "email"], "limit": 20, "order": "create_date desc" },
"odoo_instance": {
"url": "https://erp.example.com",
"db": "prod",
"user_id": 2,
"api_key": "<the Odoo user API key>"
}
}Two domain triples with no explicit operator are AND-ed together. The full operator set — like, ilike,
in, child_of, and the &/|/! logic — is covered in
Odoo domain filters, and relational (dotted-path) filters like
partner_id.country_id.code are in
relational domain filters.
The HTTP 200 trap, on either transport
Both transports share a failure mode that surprises people: a response can look like success and still
carry an error. Odoo speaks JSON-RPC 2.0 semantics under the hood, and a logic error — a bad API key, a
missing access right, a malformed domain — comes back as an error payload, not an HTTP failure. Through
the proxy that means an HTTP 200 with a populated error object:
{
"jsonrpc": "2.0",
"id": "search-read-1",
"error": {
"code": 200,
"message": "Invalid field 'emial' on model 'res.partner'",
"data": { "name": "odoo.exceptions.ValueError" }
}
}The error.code of 200 there is Odoo's own server-side code, not the HTTP status — a typo in a
field name surfaces exactly this way. Only proxy-layer failures (a wrong x-api-key → 401/-32000, an
action outside the allowlist → 400/-32001, an upstream timeout → 504/-32003) use a non-200
status.
200 is not proof of success. Check the HTTP status first, and then — even on a 200 — check whether the body has a populated error field before you read result. The full two-layer model is in Odoo API error handling.Which transport should you use?
- New integration? Reach for JSON-RPC. It's easier to debug, plays nicely with every HTTP client, and shares one envelope with every other action. Through a proxy you also get a single endpoint for many instances and can centralize auth and timeouts.
- Maintaining a legacy XML-RPC integration? There's no urgency to rewrite it — XML-RPC still works in
Odoo 16, 17, and 18. But new code in the same project doesn't have to match the old transport;
search_readbehaves identically, so you can add JSON-RPC callers alongside the XML-RPC ones.
ODXProxy is early (v0.1.0), so treat anything beyond its nine shipped actions as roadmap rather than a
promise. The search_read behavior itself is stable across recent Odoo releases, on both transports.
Where to go next
- Go deep on the query itself in Odoo search_read, in depth.
- Compare the transports at the API level in the Odoo External API: a JSON-RPC guide.
- Build precise filters with Odoo domain filters.
- Read the full request/response contract in the API reference, or skip the envelope boilerplate with the Python SDK.