← Blog
odooormsecurityrecord-rulesjson-rpc

Why Odoo Returns Fewer Records Over the API

Odoo record rules (ir.rule) are silently ANDed into every search over the API, so the same domain returns different rows per user. How to diagnose and fix it.

ODXProxy Team · Jul 20, 2026 · 9 min read

Why Odoo Returns Fewer Records Over the API — ODXProxy blog cover

You run a search_read against sale.order with an empty domain. The Odoo web UI shows 4,812 orders. Your API call returns 214. No error, no warning, HTTP 200, a perfectly well-formed result array — just the wrong number of rows. This is almost always Odoo record rules (ir.rule) doing exactly what they were designed to do: silently filtering every search by the acting user's permissions. This guide explains why an Odoo API call returns fewer records than expected, how to confirm record rules are the cause, and the three practical ways to fix it.

Access rights and record rules are two different things

Odoo has two independent permission layers, and only one of them is loud about being enforced.

  • Access rights (ir.model.access) are model-level: can this group read/write/create/delete sale.order at all? A failure here raises an AccessError — you get a clear message naming the model.
  • Record rules (ir.rule) are row-level: which sale.order records may this user see? A rule is a domain that Odoo injects into your query's WHERE clause.

That difference in enforcement is the whole problem. When access rights fail, you get an error. When record rules apply to a search, you get no error at all — the non-matching rows simply are not in the result set. Your domain and the rule's domain are ANDed together in SQL, and you only ever see the intersection.

The asymmetry is worth internalising: on search and search_read, record rules filter. On write, unlink, or a read of ids you named explicitly, a rule violation raises an access error instead. Same rules, two very different symptoms.

How rules combine

A model can have many rules at once, and they do not simply stack:

  • A rule with no groups is a global rule. Every global rule on the model is ANDed — all of them must pass.
  • Rules attached to groups are ORed among the groups the user actually belongs to — any one of them passing is enough.
  • The group-rule result is then ANDed with the global rules.

So adding a user to one more group can widen what they see (another ORed rule), but a global rule can never be escaped by group membership. This is why "give them the Sales Manager group" sometimes fixes an API visibility problem and sometimes changes nothing at all.

Each rule also declares which operations it covers, via the perm_read, perm_write, perm_create and perm_unlink flags. A rule with perm_read unset does not affect your search_read at all — which is a common source of "but I checked that rule, it looked fine."

Step 1: confirm it is really the rules

Before you go rule-hunting, prove the row count depends on who is asking. The cleanest test is a search_count with an empty domain, run as two different Odoo users. Because the target credentials live in odoo_instance on every request, that is just two payloads with a different user_id and api_key:

{
  "id": "rules-count-1",
  "action": "search_count",
  "model_id": "sale.order",
  "params": [[]],
  "keyword": {},
  "odoo_instance": {
    "url": "https://erp.example.com",
    "db": "prod",
    "user_id": 7,
    "api_key": "<the Odoo user API key>"
  }
}

Send it again with the user_id/api_key of an administrator. Two different counts for the same empty domain means row-level filtering, full stop — an empty domain matches everything the caller is allowed to see, so the only variable left is the caller.

Remember which key is which here: the x-api-key HTTP header authenticates you to the proxy, while odoo_instance.api_key is the Odoo user's key and is what determines which record rules apply. If that split is new, start with how to authenticate to the Odoo API.

Step 2: read the rules that apply

ir.rule is an ordinary Odoo model, so you can query it like any other. To list every active rule on sale.order:

{
  "id": "rules-list-1",
  "action": "search_read",
  "model_id": "ir.rule",
  "params": [[["model_id.model", "=", "sale.order"], ["active", "=", true]]],
  "keyword": {
    "fields": ["name", "domain_force", "groups", "global", "perm_read"]
  },
  "odoo_instance": {
    "url": "https://erp.example.com",
    "db": "prod",
    "user_id": 2,
    "api_key": "<the Odoo user API key>"
  }
}

Note the dotted path model_id.modelir.rule.model_id is a many2one to ir.model, so you traverse into it rather than looking up the model id first. That technique is covered in depth in filtering Odoo relational fields in domains.

The field that matters is domain_force: it is the rule's domain, stored as a string containing a Python expression, not as JSON. It is evaluated server-side with user and company_ids in scope, so you will see things like:

[('user_id', '=', user.id)]
[('company_id', 'in', company_ids)]
['|', ('partner_id', '=', user.partner_id.id), ('user_id', '=', user.id)]

Read those as "this is silently appended to every search you run." The first one is the classic culprit: the API user only sees orders where they are the assigned salesperson.

Reading ir.rule itself is subject to access rights, and on many databases only administrators can. If this call comes back as an access error, run the introspection with an admin user's key — do not conclude that the model has no rules.

Step 3: pick a fix

There are three honest options, and the right one is a security decision rather than a technical one.

Option A — give the integration user the right groups

If the rules that are filtering you are group rules, adding the API user to a group that carries a broader rule widens the result set, because group rules OR together. This is the least invasive fix and the one to reach for first. It fails when the offending rule is global — no amount of group membership relaxes a global rule.

Option B — fix the context, not the permissions

A large share of "missing records" cases are not really permission problems at all: they are multi-company problems. The standard company rule is roughly [('company_id', 'in', company_ids)], and company_ids comes from the allowed companies in the request context. A user with access to three companies still sees only the companies currently enabled in their context.

You can set that explicitly per call, because execute_kw keyword arguments accept a context:

{
  "id": "rules-ctx-1",
  "action": "search_read",
  "model_id": "sale.order",
  "params": [[]],
  "keyword": {
    "fields": ["name", "partner_id", "amount_total"],
    "limit": 100,
    "context": { "allowed_company_ids": [1, 3] }
  },
  "odoo_instance": {
    "url": "https://erp.example.com",
    "db": "prod",
    "user_id": 7,
    "api_key": "<the Odoo user API key>"
  }
}

The user must genuinely have access to companies 1 and 3 — this widens the context, it does not grant permission. But if your integration was quietly scoped to one company because nobody set allowed_company_ids, this single keyword fixes it without touching any security configuration.

Option C — a dedicated rule for the integration user

When an integration legitimately needs to see everything on a model, the maintainable answer is a purpose-built group with its own ir.rule (typically [(1, '=', 1)], which matches all rows) and an API user in that group. It is explicit, auditable, and scoped to the models you actually integrate with.

What you should not reach for is the superuser. Odoo does skip record rules for uid 1, but that account is not meant to authenticate over the external API, and building an integration around it trades a five-minute configuration task for a permanent security hole. Treat "run as superuser" as a non-option.

The pattern that hides this bug for months

Record-rule filtering is dangerous precisely because it degrades quietly. Two habits make it worse:

Pagination that trusts the page size. If you page with limit and offset and stop when a page comes back short, rules can hand you a short page that is not the last page. Always drive pagination from a search_count run with the same domain and the same credentials, so the total you page towards was computed under the same rules as the pages themselves.

Testing as an admin. An integration developed with an administrator key and deployed with a restricted service-account key will behave differently, with no error to mark the transition. Run your integration tests with the exact credentials production will use.

Rules apply per user, not per API key, and the acting user is whatever odoo_instance.user_id says. Routing several integrations through one proxy but different Odoo users is a legitimate way to scope each one's visibility — the proxy forwards the credentials it is given on each request.

A quick word on errors you will see

Not every permission problem is silent. If you write to a record that the rules exclude, or read a list of ids where one is out of bounds, Odoo raises an access error — and that error comes back with HTTP 200 and a populated error object, because Odoo-side logic errors are passed straight through the JSON-RPC envelope. Only proxy-layer failures (bad x-api-key, upstream timeouts, connection failures) use non-200 status codes.

So the check is always two steps: look at the HTTP status first, then look for an error field in the body before you touch result. A client that branches on status alone will treat an access error as a success with a missing result. The full pattern is in Odoo API error handling.

Checklist

When an Odoo API call returns fewer records than the UI:

  1. Run search_count with an empty domain as the API user and as an admin. Different numbers → row-level filtering.
  2. search_read on ir.rule filtered by model_id.model to see the actual domain_force expressions, and check perm_read is set on the rule you suspect.
  3. Check allowed_company_ids in the context before you change any permission — multi-company scoping is the most common cause and the cheapest fix.
  4. Widen via group membership if the rule is a group rule; add a dedicated rule if it is global.
  5. Re-run your pagination against a search_count taken with the same credentials.

Where to go next