Three methods on every Odoo recordset return a new recordset attached to a modified environment, and it’s tempting to treat them as interchangeable ways to “just make this work” when a permission error gets in the way during development. They do three genuinely different things, and using the wrong one is exactly how the access-control layers from the last article get bypassed somewhere they shouldn’t be.

The short version: sudo() bypasses access rights and record rules while keeping the same acting user for traceability. with_user() actually changes who's performing the operation. with_context() changes neither — it only attaches extra metadata the ORM or specific field logic can read. Reaching for sudo() to silence a permission error you haven't actually understood yet is how a record rule meant to protect data gets quietly routed around.

sudo(): same user, bypassed checks

Per Odoo’s own ORM reference, sudo() returns a recordset in superuser mode: it ignores all access rights and record rules (hard-coded group/user checks in custom code may still apply), while keeping the original acting user’s identity intact — a record created under sudo() is still attributed to the real user who triggered it, not to an anonymous superuser. This is a deliberate, relatively recent design point: before it existed, the only way to bypass checks was with_user(SUPERUSER_ID), which lost exactly this traceability.

# The user's own group may not have write access to this model —
# sudo() bypasses that check, but the record is still logged as
# created/modified by the real user, not by an anonymous superuser.
self.env['some.model'].sudo().create({'name': 'value'})

That “ignores all access rights and record rules” is precisely what makes sudo() dangerous to reach for casually: it’s not a targeted bypass of the one specific rule that’s in the way, it’s every check, everywhere, for every operation performed on that recordset from that point on. Odoo’s own documentation is explicit that sudo() and with_user() “should generally be avoided, and only used with extreme care” — and that code relying on either should validate user input as strictly as it can, since neither mode has the normal safety net of access rights catching a mistake.

with_user(): actually changes who’s acting

with_user(some_user) returns a recordset attached to a different real user’s environment — not superuser mode, an actual specific user, complete with that user’s own access rights, record rules, and language/timezone preferences. This is the right tool for “run this specific operation as if a different real person triggered it” — a scheduled action that needs to create records attributed to a service account, for instance — where sudo()’s “same user, no checks” model is the wrong shape entirely.

# Genuinely acts AS that user — their access rights and record
# rules apply, their timezone/language apply, and the record is
# attributed to them, not to whoever actually triggered this code.
self.env['sale.order'].with_user(service_account_user).create({...})

with_context(): neither user nor permissions change

with_context() doesn’t touch access control or user identity at all — it attaches extra key/value metadata (the context dict) that specific field logic, view logic, or your own code can read to change behavior. context.get('lang') changes which translation a translated field reads; context.get('force_company') can redirect which company’s data a query targets; a custom context.get('skip_some_validation') your own code checks for is just as valid a use.

# No permission or user change at all — just extra metadata some
# field or method along the way might choose to read.
self.env['product.template'].with_context(lang='es_ES').read(['name'])

The mistake this invites: assuming a context flag restricts something the way a record rule does. It doesn’t enforce anything by itself — if no code actually checks for that context key, setting it does nothing at all, silently. A with_context() call that looks like a security control, unless something in the call chain actually reads that key, isn’t one.

Comparison

MethodChanges acting user?Bypasses access rights/record rules?What it actually does
sudo()No — same user, attributed correctlyYes — all of themSuperuser mode, same identity
with_user(u)Yes — becomes uNo — u’s own rights/rules applyGenuinely acts as a different real user
with_context(...)NoNoAttaches metadata; enforces nothing by itself

Where this actually goes wrong

The recurring real-world bug shape: a permission error surfaces during development, sudo() gets added to make it go away, and it ships that way — not because the check was actually wrong, but because nobody went back to confirm why the access rights or record rule denied it in the first place. Every sudo() call is worth being able to answer one question about: which specific check is this bypassing, and is that the intended behavior for every caller of this code path, not just the one that prompted adding it.

Watch out: a sudo() call inside a method that's reachable from a controller, an API endpoint, or anything else a less-trusted caller can trigger directly inherits all of that risk — it's not scoped to "just this one record", it applies to every access rights and record rule check for as long as the recordset stays in sudo mode, including any further ORM calls chained off it.

Frequently asked questions

Does sudo() bypass field-level security (groups on a field) too?

Access rights and record rules are what sudo() is documented to bypass — field-level groups restrictions in a view are a separate, UI-layer mechanism (see the three-layers article) that isn't about ORM-level read/write checks in the same way, so it isn't the same kind of bypass at all.

Is sudo().with_user(x) a valid combination?

Chaining is valid syntactically, but combining them defeats the point of using with_user() in the first place — you'd be acting as a specific user while also bypassing that same user's access rights and record rules, which is rarely the actual intent.

Does with_context() persist across separate ORM calls?

No — each call returns a new recordset with the extended context; it doesn't mutate anything globally or persist to a later, separately-obtained recordset for the same records. Chain it onto the specific call that needs it.

Why did Odoo add sudo() if with_user(SUPERUSER_ID) already existed?

Specifically for traceability: with_user(SUPERUSER_ID) makes every affected record look like it was created or modified by the superuser account, losing the real acting user's identity in logs and audit fields. sudo() bypasses the same checks while correctly attributing the action to the real user.

Is there a safer alternative to reaching for sudo() when a permission error appears?

Understand which layer is actually denying it first — often the real fix is adding the correct access-rights line or record rule for the group that legitimately needs access, rather than bypassing the check entirely for every caller of that code.

Further reading

And on this site: Why Can’t This User See a Record They Should? for the three layers sudo() bypasses, and the Domain Filter Builder & Explainer for testing a record rule’s domain directly.