← Blog
odooormsearch-readjson-rpc

Odoo search_read: A Practical Example

search_read runs a domain filter and reads fields in one round trip. Here's how params and keyword fit together, how to build domains, and how to paginate.

ODXProxy Team · Jul 3, 2026 · 8 min read

Odoo search_read: A Practical Example — ODXProxy blog cover

If you've ever called Odoo's search and gotten back a bare list of ids, then had to make a second read call to turn those ids into actual data, search_read is the shortcut you want. This Odoo search_read example walks through the whole thing: how the domain filter and field list map onto the request, how to build filters that combine conditions, and how to page through large result sets — all in a single round trip.

What search_read does

search and read are two halves of one job. search takes a domain filter and returns the ids of matching records. read takes ids and returns field values. Doing them separately means two API calls and some id-juggling in your client. search_read fuses them: give it a domain and a list of fields, and it returns the matching records with those fields already populated.

search_read combines a search and a read into one round trip: a domain filter goes in, matching records with the requested fields come back

It is one of the nine actions ODXProxy exposes directly (alongside search, read, search_count, create, write, unlink, fields_get, and call_method), so you can call it by name without routing through call_method.

The request shape: params vs keyword

Every call to POST /api/odoo/execute splits its arguments into two fields, and search_read is the clearest place to understand the split:

  • params is a JSON array of positional arguments. For search_read, the first positional argument is the domain — so the domain sits one level deep inside params.
  • keyword is a JSON object of keyword arguments: fields, limit, offset, order.

Here's a request that reads the name and email of up to 50 companies:

{
  "id": "sr-1",
  "action": "search_read",
  "model_id": "res.partner",
  "params": [[["is_company", "=", true]]],
  "keyword": { "fields": ["name", "email"], "limit": 50 },
  "odoo_instance": {
    "url": "https://erp.example.com",
    "db": "prod",
    "user_id": 2,
    "api_key": "<the Odoo user API key>"
  }
}

That params value looks over-nested until you unpack it: params is the argument list, its single element is the domain, and the domain is itself a list of conditions. So [[["is_company", "=", true]]] means "one positional argument, which is a domain containing one condition."

The same call as a raw HTTP request looks like this — note the two separate credentials, the proxy's x-api-key header and the Odoo user's api_key inside the body:

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": "sr-1",
    "action": "search_read",
    "model_id": "res.partner",
    "params": [[["is_company", "=", true]]],
    "keyword": { "fields": ["name", "email"], "limit": 50 },
    "odoo_instance": {
      "url": "https://erp.example.com", "db": "prod",
      "user_id": 2, "api_key": "<the Odoo user API key>"
    }
  }'

The response is a 200 with the records in result:

{
  "jsonrpc": "2.0",
  "id": "sr-1",
  "result": [
    { "id": 9, "name": "Gemini Furniture", "email": "info@gemini.example" },
    { "id": 14, "name": "Azure Interior", "email": "hello@azure.example" }
  ]
}
Relational fields come back as an [id, display_name] pair, not a bare id. Reading country_id, for example, yields something like [233, "United States"] — handy, because you get the label without a second lookup.

Building the domain filter

The domain is where search_read earns its keep. A domain is a list of conditions, each a [field, operator, value] triple. List several and they are combined with AND by default:

"params": [[
  ["is_company", "=", true],
  ["country_id", "!=", false]
]]

That reads "companies that have a country set." These are the operators you'll reach for most:

OperatorMatches when the field…
=, !=equals / does not equal the value
>, >=, <, <=compares numerically or by date
like, not likecontains the substring (case-sensitive)
ilike, not ilikecontains the substring (case-insensitive) — the usual choice for text search
in, not inis (not) one of a list of values
child_ofis a descendant of the given record in a parent/child hierarchy

So a case-insensitive name search for anything containing "furniture" is ["name", "ilike", "furniture"].

To combine conditions with OR, Odoo uses prefix (Polish) notation: an operator token comes before the operands it joins. "|" is OR, "&" is AND (the default), and "!" is NOT:

"params": [[
  "|",
  ["is_company", "=", true],
  ["email", "!=", false]
]]

This matches records that are companies or have an email address. The "|" applies to the two conditions that follow it. Nest the operators to build richer logic — each binary operator consumes the next two terms.

Combining AND and OR

Real filters mix both. Say you want US-based companies or any partner that belongs to a parent company. Read it as OR(AND(company, US), has-parent):

"params": [[
  "|",
    "&",
      ["is_company", "=", true],
      ["country_id.code", "=", "US"],
  ["parent_id", "!=", false]
]]

The leading "|" joins the next two complete expressions: the first is the "&" group (a company and in the US), the second is the single parent_id condition. Note country_id.code — Odoo domains let you traverse relational fields with a dotted path, so you can filter on the country's code without a separate lookup. (This dotted traversal works in domains, but not in the fields list — fields reads only direct fields on the model.)

A common mistake is comparing a many2one field to false to mean "empty." Use ["country_id", "=", false] for "no country," not ["country_id", "=", null] — Odoo domains use false, not null.

Choosing fields (and why you always should)

If you omit fields, Odoo returns every field on the model — often dozens, including large text and computed fields you don't need. Always pass an explicit fields list: it cuts payload size, reduces load on the Odoo server, and makes your response predictable.

"keyword": { "fields": ["name", "email", "country_id"] }
A subtlety worth knowing: you can only filter on stored fields. A non-stored computed field can be read (Odoo computes it on the fly), but putting it in a domain raises an error. If you need to filter on a computed value, define the field with store=True so it lands in the database.

If you only need to know how many records match — not their contents — don't use search_read at all; use search_count, which returns a single integer and skips reading field data entirely.

Discovering field names with fields_get

If you're not sure what a model's fields are called, ask Odoo. The fields_get action describes a model's fields — their technical names, types, labels, and whether they're stored:

{
  "id": "fg-1",
  "action": "fields_get",
  "model_id": "res.partner",
  "params": [],
  "keyword": { "attributes": ["string", "type"] }
}

Use it once while building an integration to get the exact field names, then pass those into search_read's fields list.

Pagination and ordering

Large result sets should be paged, not pulled all at once. Three keyword arguments handle it:

  • limit — the maximum number of records to return.
  • offset — how many matching records to skip before returning.
  • order — a SQL-style sort string, e.g. "name asc" or "create_date desc".

To walk a large partner list in pages of 100, ordered by name:

{
  "id": "sr-page-2",
  "action": "search_read",
  "model_id": "res.partner",
  "params": [[["is_company", "=", true]]],
  "keyword": {
    "fields": ["name", "email"],
    "order": "name asc",
    "limit": 100,
    "offset": 100
  }
}

Increment offset by limit for each page. Keep the order stable across pages, otherwise records can shift between pages as you go. For very large exports, pair a search_count up front so you know how many pages to expect.

search_read has no default limit. Omit limit on a big model and you'll pull every matching record in one response — always set a limit in production code, even if it's a generous one.

search_read vs search, read, and search_count

Reach for the action that returns exactly what you need and no more:

  • search_count — you only need the number of matches.
  • search — you only need ids (for example, to feed another action like write or unlink).
  • read — you already have ids and want their fields.
  • search_read — you have a filter and want the matching records' fields. This is the everyday choice for fetching data.

Don't forget the response check

Like every call through the proxy, a search_read can come back as HTTP 200 and still carry an error object — an invalid field name or a permissions problem surfaces there, not as a non-200 status. Check the HTTP status first, then check for a populated error before reading result. The full two-step pattern, and the proxy error codes, are covered in how to authenticate to the Odoo API.

Where to go next

search_read is the workhorse of reading data from Odoo: one call, a precise domain, an explicit field list, and paging for scale.