Server actions are Odoo's mechanism for executing arbitrary Python code from a UI interaction, a cron job, or an automation rule - without writing a full module method. They sit between "this is simple enough for a computed field" and "this needs a proper Python method in a model class." Used correctly, they handle one-off business logic cleanly. Misused, they scatter business logic into database records that are impossible to test and hard to version-control.
What server actions are#
A server action is a record in ir.actions.server. It stores Python code (as a string) and a model binding. When triggered, the ORM evaluates the code in a sandboxed environment with access to:
env- the Odoo ORM environment.model- the bound model.records- the recordset the action was triggered on.record- the first record (shorthand forrecords[0]).
Create in Settings → Technical → Actions → Server Actions.
Types of server actions#
| Type | What it does |
|---|---|
| Python Code | Executes arbitrary Python. Most flexible. |
| Create Record | Creates a new record with field values. |
| Update Record | Sets field values on the current record. |
| Multi Actions | Executes a list of other server actions in sequence. |
| Send Email | Sends an email template. |
| Add Followers | Subscribes partners to the record's chatter. |
For most business logic needs, "Python Code" is the right choice. The declarative types (Create Record, Update Record) are useful only when building integrations from the UI without coding.
When to use server actions#
Use server actions for:
- One-off data migration scripts run via a manual button.
- Business logic triggered by a button that does not belong in the model's core API.
- Automation rules (triggered by record lifecycle events).
- Scheduled cron jobs with simple logic.
- Sending a contextual notification (email, SMS) in response to a specific state change.
Do not use server actions for:
- Logic that is always applied when a field changes (use
@api.onchangeor_compute_). - Reusable business logic called from multiple places (write a model method).
- Complex multi-step workflows (use proper Python code in the module).
- Logic that needs unit tests (code in server action records is not testable with pytest/Jest).
Triggering from a button#
Add a button to a form view that triggers a server action:
<!-- mymodule/views/sale_order_views.xml -->
<record id="view_sale_order_form_custom" model="ir.ui.view">
<field name="model">sale.order</field>
<field name="inherit_id" ref="sale.view_order_form"/>
<field name="arch" type="xml">
<xpath expr="//button[@name='action_confirm']" position="after">
<button name="%(mymodule.action_send_internal_alert)d"
string="Send Internal Alert"
type="action"
class="oe_highlight"
attrs="{'invisible': [('state', '!=', 'sale')]}"/>
</xpath>
</field>
</record>The type="action" attribute tells Odoo this button triggers a server action (not a Python method). The name is the XML ID of the action using the %(xmlid)d syntax.
Define the server action:
<record id="action_send_internal_alert" model="ir.actions.server">
<field name="name">Send Internal Alert</field>
<field name="model_id" ref="sale.model_sale_order"/>
<field name="binding_model_id" ref="sale.model_sale_order"/>
<field name="state">code</field>
<field name="code">
for record in records:
record.message_post(
body=f"Order {record.name} flagged for internal review.",
message_type='comment',
subtype_xmlid='mail.mt_note',
)
record.write({'x_internal_review': True})
</field>
</record>Using server actions in automated actions#
Automated actions (Settings → Technical → Automation → Automated Actions) call a server action when a trigger fires. The code block in the automated action IS a server action (the fields are shared).
Example: send a Slack-style internal note when a sale order reaches a specific amount threshold:
# Automated action code
if record.amount_total > 50000:
record.activity_schedule(
'mail.mail_activity_data_todo',
user_id=record.company_id.x_high_value_owner_id.id,
note=f'High-value order {record.name}: {record.amount_total} {record.currency_id.symbol}',
)Cron jobs via server actions#
Scheduled actions (Settings → Technical → Automation → Scheduled Actions) execute server action code on a timer.
The cron server action does not receive a records recordset. Instead, query what you need:
# Scheduled action code - no 'records' variable
SaleOrder = env['sale.order']
overdue = SaleOrder.search([
('state', '=', 'sale'),
('x_due_date', '<', fields.Date.today()),
('x_overdue_notified', '=', False),
])
for order in overdue:
order.message_post(
body='This order is overdue.',
partner_ids=order.partner_id.ids,
)
order.x_overdue_notified = TrueLimitations and gotchas#
No transaction rollback on error: If the server action raises an exception mid-execution, Odoo may partially commit changes depending on whether the action is called within a with env.cr.savepoint() block. Wrap destructive operations:
with env.cr.savepoint():
for record in records:
record.action_risky_operation()No version control: Server action code lives in the database, not in git. Developers edit it in the UI. This makes change tracking, code review, and rollback painful. For anything non-trivial, write a proper model method and call it from a minimal server action:
# In server action - just a dispatcher
records._my_module_process_alert()# In mymodule/models/sale_order.py - the real logic
def _my_module_process_alert(self):
for record in self:
# Testable business logic here
...Performance: Server actions called on large recordsets process all records in a single transaction. For bulk operations on thousands of records, use a BullMQ-style background job or a cron with batching logic.

