GuideOdoo AutomationMarch 13, 2026

Server Actions in Odoo 19:
Automate Business Logic Without Custom Code

INTRODUCTION

Your Team Is Writing Python Modules for Logic That Odoo Can Handle Out of the Box

We audit Odoo implementations every week. The pattern is always the same: a developer wrote a 200-line Python module to auto-assign sales orders to a team, another module to send a reminder email when an invoice is 7 days overdue, and a third to archive inactive products. Each module has an __manifest__.py, a model file, maybe a cron XML — and each one needs to be maintained, tested, and upgraded across Odoo versions.

Every one of those use cases could have been a Server Action — configured in the UI in 10 minutes, with zero deployment, zero module dependencies, and zero upgrade friction. Server Actions are Odoo's built-in automation engine, and in Odoo 19 they've become powerful enough to replace a significant portion of custom module code.

This guide covers everything you need to build production-grade automations using Server Actions: every action type, the Python execution context, trigger conditions, scheduled actions (cron), chaining actions into sequences, debugging strategies, and the performance traps that catch most teams.

01

The 6 Server Action Types in Odoo 19 and When to Use Each One

Server Actions live under Settings → Technical → Server Actions (enable Developer Mode first). Each action targets a specific model and performs one of six operations. Understanding which type fits your use case saves you from overcomplicating things with "Execute Python Code" when a simpler action type would work.

Action TypeWhat It DoesUse CaseRequires Code
Execute CodeRuns arbitrary Python in a sandboxed contextComplex logic, multi-model updates, conditional branchingYes
Update RecordSets field values on the current record(s)Change stage, assign user, set priority, toggle booleanNo
Create RecordCreates a new record in any modelAuto-create tasks from leads, generate follow-up activitiesNo
Send EmailSends an email using a mail templateNotifications, reminders, approvalsNo
Add FollowersSubscribes users/partners to the record's chatterAuto-subscribe managers, CC stakeholders on ticketsNo
Send SMSSends an SMS using an SMS templateDelivery notifications, appointment remindersNo
Start Without Code

Before reaching for "Execute Code," ask: can this be done with "Update Record" or "Create Record"? The no-code action types are faster to configure, easier to maintain, and immune to Python syntax errors. We've seen teams write 30-line Python blocks to set three fields on a record — something "Update Record" handles with three dropdown selections and zero risk of runtime exceptions.

Configuring an Update Record Action via XML

While you can configure Server Actions entirely through the UI, defining them in XML data files makes them version-controlled and deployable across environments. Here's a Server Action that marks overdue tasks as high priority:

XML — data/server_action_task_priority.xml
<odoo>
  <data noupdate="1">

    <!-- Server Action: Set overdue tasks to high priority -->
    <record id="action_task_set_high_priority" model="ir.actions.server">
      <field name="name">Set Overdue Tasks to High Priority</field>
      <field name="model_id" ref="project.model_project_task"/>
      <field name="binding_model_id" ref="project.model_project_task"/>
      <field name="binding_view_types">list</field>
      <field name="state">object_write</field>
      <field name="update_field_id"
             ref="project.field_project_task__priority"/>
      <field name="update_related_model_id"
             ref="project.model_project_task"/>
      <field name="value">1</field>
      <field name="value_type">value</field>
    </record>

  </data>
</odoo>

Key fields explained: state determines the action type — object_write for Update Record, object_create for Create Record, code for Execute Code, email for Send Email, followers for Add Followers, and sms for Send SMS. The binding_model_id adds the action to the "Action" dropdown menu on the model's list/form views, letting users trigger it manually on selected records.

02

The Python Execution Context: What Variables Are Available in Execute Code Actions

When you choose "Execute Code," Odoo runs your Python snippet inside a restricted execution environment. Understanding exactly what's available — and what's not — prevents the most common runtime errors. Here's the full context available in Odoo 19:

VariableTypeWhat It Gives You
envEnvironmentFull ORM environment. Access any model via env['model.name']
modelModelEmpty recordset of the action's target model
recordsRecordsetThe record(s) the action is running on (may be empty for scheduled actions)
recordRecordAlias for records[0] when triggered on a single record
logfunctionWrites to ir.logging (visible in Settings → Technical → Logging)
timemodulePython time module
datetimemodulePython datetime module (includes date, timedelta)
dateutilmoduleThe dateutil library for advanced date parsing
timezonefunctionConverts between timezones (pytz)
float_comparefunctionPrecision-safe float comparison from odoo.tools
b64encode / b64decodefunctionBase64 encoding for binary fields (attachments)
Warningexceptionodoo.exceptions.UserError — raise to show error to user

Here's a real-world Execute Code action that auto-assigns sales orders to sales teams based on the customer's country, creates a follow-up activity, and logs the assignment:

Python — Auto-assign sales orders by customer country
# Available context: env, model, records, record, log,
#   time, datetime, dateutil, timezone, Warning

COUNTRY_TEAM_MAP = {{
    'US': 'sales_team_north_america',
    'CA': 'sales_team_north_america',
    'GB': 'sales_team_europe',
    'DE': 'sales_team_europe',
    'FR': 'sales_team_europe',
    'AU': 'sales_team_apac',
    'JP': 'sales_team_apac',
}}

for rec in records:
    country_code = rec.partner_id.country_id.code
    if not country_code:
        log("Skipping SO %s: partner has no country" % rec.name,
            level='warning')
        continue

    team_xmlid = COUNTRY_TEAM_MAP.get(country_code)
    if team_xmlid:
        team = env.ref('sales_team.%s' % team_xmlid,
                        raise_if_not_found=False)
        if team:
            rec.write({{
                'team_id': team.id,
                'user_id': team.user_id.id or False,
            }})

            # Create a follow-up activity for the team leader
            rec.activity_schedule(
                'mail.mail_activity_data_todo',
                date_deadline=datetime.date.today()
                    + datetime.timedelta(days=1),
                summary='New SO assigned: %s' % rec.name,
                user_id=team.user_id.id,
            )

            log("Assigned %s to team %s (country: %s)" % (
                rec.name, team.name, country_code))
Use env.ref() With raise_if_not_found=False

If the XML ID doesn't exist — because a module was uninstalled or the data was deleted — env.ref() raises a ValueError that crashes the entire action. Always pass raise_if_not_found=False and handle the None return. This is especially important for server actions that reference data from other modules.

03

Scheduled Actions (Cron Jobs): Running Server Actions on a Timer in Odoo 19

Scheduled Actions are Server Actions with a clock. They run automatically at intervals you define — every hour, every day, every Monday at 6 AM. Under the hood, a Scheduled Action is an ir.cron record linked to an ir.actions.server record. The cron worker triggers the server action with an empty recordset, so your code must query for the records it needs.

Here's a Scheduled Action that finds invoices overdue by more than 7 days, sends a reminder email, and escalates invoices overdue by 30+ days to the finance manager:

XML — data/cron_overdue_invoice_reminder.xml
<odoo>
  <data noupdate="1">

    <!-- Server Action: Process overdue invoices -->
    <record id="action_overdue_invoice_reminder"
            model="ir.actions.server">
      <field name="name">Process Overdue Invoice Reminders</field>
      <field name="model_id" ref="account.model_account_move"/>
      <field name="state">code</field>
      <field name="code">
today = datetime.date.today()
week_ago = today - datetime.timedelta(days=7)
month_ago = today - datetime.timedelta(days=30)

# Find posted invoices that are overdue
overdue_invoices = env['account.move'].search([
    ('move_type', '=', 'out_invoice'),
    ('state', '=', 'posted'),
    ('payment_state', 'in', ['not_paid', 'partial']),
    ('invoice_date_due', '&lt;=', str(week_ago)),
])

if not overdue_invoices:
    log("No overdue invoices found")

# Separate into reminder vs escalation buckets
reminder_invoices = overdue_invoices.filtered(
    lambda inv: inv.invoice_date_due >= str(month_ago)
)
escalation_invoices = overdue_invoices.filtered(
    lambda inv: inv.invoice_date_due &lt; str(month_ago)
)

# Send reminder email (7-30 days overdue)
reminder_template = env.ref(
    'account.email_template_edi_invoice',
    raise_if_not_found=False,
)
if reminder_template and reminder_invoices:
    for inv in reminder_invoices:
        reminder_template.send_mail(inv.id, force_send=False)
    log("Sent %d reminder emails" % len(reminder_invoices))

# Escalate to finance manager (30+ days overdue)
finance_manager = env.ref(
    'base.user_admin', raise_if_not_found=False
)
if finance_manager and escalation_invoices:
    for inv in escalation_invoices:
        inv.activity_schedule(
            'mail.mail_activity_data_todo',
            date_deadline=today,
            summary='ESCALATION: Invoice %s overdue %d days'
                % (inv.name,
                   (today - inv.invoice_date_due).days),
            user_id=finance_manager.id,
        )
    log("Escalated %d invoices" % len(escalation_invoices))
      </field>
    </record>

    <!-- Cron: Run every day at 6:00 AM -->
    <record id="cron_overdue_invoice_reminder" model="ir.cron">
      <field name="name">Overdue Invoice Reminder</field>
      <field name="model_id" ref="account.model_account_move"/>
      <field name="state">code</field>
      <field name="code">
        model.env.ref(
            'my_module.action_overdue_invoice_reminder'
        ).run()
      </field>
      <field name="interval_number">1</field>
      <field name="interval_type">days</field>
      <field name="numbercall">-1</field>
      <field name="active">True</field>
    </record>

  </data>
</odoo>

Key details about Scheduled Actions in Odoo 19:

  • numbercall = -1 means the cron runs indefinitely. A positive integer means it runs that many times then deactivates itself. Use 1 for one-shot migration scripts.
  • interval_type accepts minutes, hours, days, weeks, and months. There is no "seconds" option — if you need sub-minute frequency, you're solving the wrong problem with cron.
  • records is empty in Scheduled Actions. The action is not triggered by a specific record, so you must search() for the records you want to process. Forgetting this is the #1 cause of "my cron does nothing."
  • Cron runs as OdooBot (user ID 1) by default. If your code checks env.user or uses record rules that filter by user, the results may differ from what you see in the UI.
Cron Execution Timeout

Odoo 19 enforces a default cron timeout of 1800 seconds (30 minutes) via the --limit-time-real-cron config parameter. If your scheduled action processes thousands of records and exceeds this limit, the worker is killed and the action silently stops mid-execution. Always process records in batches with env.cr.commit() between batches to avoid losing progress.

04

Chaining Multiple Actions with Server Action Sequences

Sometimes a single action type isn't enough. You need to update a field, then send an email, then create a follow-up task — all triggered by one event. Odoo 19 supports this with action sequences: a Server Action whose type is "Execute several actions" (state='multi'), which runs a list of child actions in order.

XML — data/server_action_lead_won_sequence.xml
<odoo>
  <data noupdate="1">

    <!-- Step 1: Mark lead as won -->
    <record id="action_lead_won_update" model="ir.actions.server">
      <field name="name">Lead Won: Update Fields</field>
      <field name="model_id" ref="crm.model_crm_lead"/>
      <field name="state">object_write</field>
      <field name="update_field_id"
             ref="crm.field_crm_lead__priority"/>
      <field name="value">3</field>
      <field name="value_type">value</field>
    </record>

    <!-- Step 2: Send congratulations email -->
    <record id="action_lead_won_email" model="ir.actions.server">
      <field name="name">Lead Won: Send Email</field>
      <field name="model_id" ref="crm.model_crm_lead"/>
      <field name="state">email</field>
      <field name="template_id"
             ref="crm.mail_template_lead_won"/>
    </record>

    <!-- Step 3: Create onboarding task -->
    <record id="action_lead_won_task" model="ir.actions.server">
      <field name="name">Lead Won: Create Task</field>
      <field name="model_id" ref="crm.model_crm_lead"/>
      <field name="state">object_create</field>
      <field name="crud_model_id" ref="project.model_project_task"/>
      <field name="link_field_id"
             ref="project.field_project_task__sale_order_id"/>
      <field name="fields_lines">
        <field name="col1"
               ref="project.field_project_task__name"/>
        <field name="value">
          Client Onboarding: {{object.partner_id.name}}
        </field>
        <field name="evaluation_type">equation</field>
      </field>
    </record>

    <!-- Sequence: Run all three in order -->
    <record id="action_lead_won_sequence" model="ir.actions.server">
      <field name="name">Lead Won: Full Sequence</field>
      <field name="model_id" ref="crm.model_crm_lead"/>
      <field name="state">multi</field>
      <field name="child_ids" eval="[
          (4, ref('action_lead_won_update')),
          (4, ref('action_lead_won_email')),
          (4, ref('action_lead_won_task')),
      ]"/>
    </record>

  </data>
</odoo>

Sequences run inside a single database transaction. If Step 3 fails, Steps 1 and 2 are rolled back. This is a feature: you don't end up with a "won" lead that has a congratulations email sent but no onboarding task. However, it also means that if any step raises an exception, nothing is saved — including the email, which was queued but not committed.

Combining Sequences with Automation Rules

You can also chain Server Actions with Automation Rules (Settings → Technical → Automation). An Automation Rule triggers a Server Action when a record is created, updated, or matches a timed condition. Here are the trigger types available in Odoo 19:

TriggerFires WhenExample Use Case
On CreationA new record is createdAuto-assign new leads to sales team based on source
On UpdateSpecific fields change valueNotify manager when opportunity stage changes to "Won"
On Creation & UpdateEither of the aboveValidate required fields on quotes regardless of how they're set
On DeletionRecord is about to be deletedArchive instead of delete, or log deletion to an audit trail
Based on Timed ConditionA date field matches a time offsetSend reminder 3 days before subscription renewal date

The combination of Automation Rules + Server Action Sequences covers 90% of the business logic that teams typically build as custom Python modules. The "Based on Timed Condition" trigger is particularly powerful — it replaces the need for most custom cron jobs by letting you express time-based logic declaratively: "7 days after invoice due date, if payment state is still 'not_paid', run this action."

Automation Rule Execution Order

When multiple Automation Rules target the same model and trigger, they execute in sequence number order (the "Sequence" field on the rule). If no sequence is set, execution order is unpredictable. Always set explicit sequences when you have interdependent rules — for example, if Rule A assigns a salesperson and Rule B sends a notification to that salesperson.

05

Debugging Server Actions: Logging, Error Handling, and the ir.logging Table

Server Actions fail silently by default. When an Execute Code action raises an exception, Odoo catches it, logs it to the server log, and shows a generic error to the user. No stack trace in the UI, no indication of which line failed. Here's how to build observable, debuggable Server Actions:

Python — Defensive Server Action with logging and error handling
# Pattern: Wrap logic in try/except, log everything,
# use Warning (UserError) for user-facing errors

log("Starting batch processing for %d records" % len(records))
processed = 0
errors = []

for rec in records:
    try:
        if not rec.partner_id:
            log("Skipping %s: no partner" % rec.display_name,
                level='warning')
            continue

        # Your business logic here
        old_state = rec.state
        rec.write({{'state': 'confirmed'}})
        log("Updated %s: %s -> confirmed" % (
            rec.display_name, old_state))
        processed += 1

    except Exception as e:
        errors.append("%s: %s" % (rec.display_name, str(e)))
        log("Error processing %s: %s" % (
            rec.display_name, str(e)), level='error')

log("Batch complete: %d processed, %d errors" % (
    processed, len(errors)))

if errors and len(errors) == len(records):
    raise Warning(
        "All records failed to process:\n" +
        "\n".join(errors[:10])
    )

The log() function writes to the ir.logging table, which you can browse at Settings → Technical → Logging. Each entry includes the timestamp, log level, function name, path, and your message. This is your primary debugging tool for Server Actions — far more reliable than print() statements, which only appear in the server stdout and are lost when the worker restarts.

Performance Considerations

Server Actions run inside the same worker process as user requests. A Server Action that takes 30 seconds to execute blocks that worker for the entire duration. On a server with 4 workers, one long-running action means 25% of your capacity is gone. Follow these rules to keep Server Actions fast:

  • Never use search() inside a loop. If you need to look up related records, fetch them all at once before the loop with a single search() and build a dictionary for O(1) lookups.
  • Prefer write() on a recordset over individual rec.write() calls. Writing to 100 records at once generates one SQL query; writing one at a time generates 100.
  • Use sudo() sparingly. It bypasses record rules, which means no security filtering — but also no index hints from the security filters. Queries on large tables without record rules can be dramatically slower.
  • Batch large operations. For Scheduled Actions processing thousands of records, use search([], limit=500) with env.cr.commit() between batches. This prevents long transactions, reduces memory usage, and makes the action resumable if it times out.

Here's the batch processing pattern we use in every Scheduled Action that touches more than a few hundred records:

Python — Batch processing pattern for Scheduled Actions
# Batch processing: avoids worker timeout and memory issues
BATCH_SIZE = 500
offset = 0
total_processed = 0

while True:
    # Fetch next batch (search is re-executed each loop
    # because committed records may change filter results)
    batch = env['product.template'].search(
        [('active', '=', True),
         ('write_date', '&lt;=',
          str(datetime.date.today()
              - datetime.timedelta(days=180)))],
        limit=BATCH_SIZE,
        order='id ASC',
    )

    if not batch:
        break

    # Process batch
    batch.write({{'active': False}})
    total_processed += len(batch)

    # Commit between batches — saves progress,
    # releases DB locks, frees memory
    env.cr.commit()

    # Clear ORM cache to prevent memory buildup
    env.invalidate_all()

    log("Archived %d products (total: %d)" % (
        len(batch), total_processed))

log("Batch complete: %d products archived" % total_processed)
06

4 Server Action Mistakes That Break Production Odoo Instances

1

Infinite Loops from Server Actions Triggering Themselves

You create an Automation Rule that fires on "record updated" for sale.order. The linked Server Action also writes to the same sale.order. That write triggers the Automation Rule again, which triggers the Server Action again, which writes again — infinite recursion until the worker OOMs or the transaction times out. Odoo 19 has basic loop detection, but it only catches the simplest cases. Cross-model chains (Action A updates sale.order, which triggers Action B that updates the same sale.order) bypass the detection entirely.

Our Fix

Use records.with_context(no_automation=True).write() when your Server Action writes back to the same model that triggered it. Check for this context key at the start of your action: if env.context.get('no_automation'): return. This breaks the loop without disabling the automation for other callers.

2

Scheduled Actions Silently Die on Worker Timeout

The Odoo cron worker has a hard timeout (default: 1800 seconds). When your Scheduled Action exceeds it, the worker process is killed by the Odoo master process. There's no exception, no retry, no log entry beyond a generic "worker killed." The action's lastcall timestamp is not updated, so on the next cron tick Odoo tries to run it again — and it times out again. You now have a scheduled action that silently fails every single run, processing zero records.

Our Fix

Always process in batches with explicit commits. Track progress via a custom field (e.g., x_last_processed_id) or a date filter so the action resumes where it left off. Set --limit-time-real-cron=3600 in your Odoo config only as a last resort — the real fix is making the action itself faster.

3

Using env.cr.commit() Without Understanding the Consequences

When processing large batches, you need env.cr.commit() to avoid holding a massive transaction. But commits have a side effect: they finalize everything, including emails queued by send_mail(). If your action commits after processing 500 records, then raises an exception on record 501, you've already sent 500 emails that can't be rolled back. You also lose the atomic guarantee — 500 records are in the new state and the rest are in the old state.

Our Fix

Separate side effects from data writes. Process all records first with write() and commit. Then queue emails in a second pass. If the email pass fails, the data is still consistent. For truly critical workflows, use force_send=False so emails go through the mail queue — which has its own retry logic — instead of sending inline.

4

Hardcoded IDs Instead of XML References

We see this constantly in Execute Code actions: env['res.users'].browse(2) or rec.write({{'stage_id': 7}}). These IDs are specific to one database. When you restore a backup to staging, migrate to a new instance, or install the action on a second database, ID 7 is a completely different stage — or it doesn't exist at all. The action either crashes or silently writes the wrong data.

Our Fix

Always use env.ref('module.xml_id') to look up records. For records without XML IDs (user-created data), use search() with a unique field like name or code. Never assume a numeric ID is stable across databases.

BUSINESS ROI

What Server Actions Save Compared to Custom Module Development

The ROI of Server Actions isn't just development time — it's the entire lifecycle cost of maintaining custom code across Odoo upgrades:

90%Faster to Implement

A Server Action takes 10-30 minutes to configure. The equivalent custom module takes 4-8 hours including manifest, models, views, security rules, and tests.

$0Upgrade Migration Cost

Server Actions survive major Odoo upgrades as data records. Custom modules need Python code changes, ORM API updates, and manifest version bumps every release.

ZeroDeployment Needed

Server Actions are database records. Create, test, and activate them in production without touching the server, restarting workers, or running module upgrades.

The breakpoint is complexity. Server Actions are ideal for automations that can be expressed in under 50 lines of Python — field updates, email triggers, record creation, activity scheduling, and conditional routing. Once your logic needs inheritance, custom fields on models, new views, or access control rules, you've crossed into custom module territory. The mistake we see most often is using custom modules for simple logic and Server Actions for complex logic — exactly backwards.

SEO NOTES

Optimization Metadata

Meta Desc

Complete guide to Odoo 19 Server Actions. Execute code, update records, send emails, schedule cron jobs, and chain action sequences — all without custom modules.

H2 Keywords

1. "The 6 Server Action Types in Odoo 19 and When to Use Each One"
2. "The Python Execution Context: What Variables Are Available in Execute Code Actions"
3. "Scheduled Actions (Cron Jobs): Running Server Actions on a Timer in Odoo 19"
4. "4 Server Action Mistakes That Break Production Odoo Instances"

Stop Writing Modules for Logic That Should Be Configuration

Server Actions are one of Odoo's most underused features. Teams spend weeks building custom Python modules for automations that could be configured in the UI in minutes. Every custom module is a future upgrade liability, a deployment dependency, and a maintenance burden. Server Actions eliminate all three — for the right use cases.

If you have 10+ custom modules and aren't sure which ones could be replaced by Server Actions, we can audit them. We review your custom code, identify automations that can be moved to Server Actions or Automation Rules, and estimate the maintenance cost savings. Most clients eliminate 30-50% of their custom module count — which directly reduces their next Odoo upgrade cost.

Book a Free Automation Audit