Scheduled actions in Odoo are the right tool for background tasks that need to run on a timer: syncing data from an external API, sending reminder emails, computing statistics, archiving stale records. The framework provides the ir.cron model for this, and it hooks directly into the ORM so your code runs inside a proper transaction with full record access.
This guide covers the full ir.cron API, common mistakes, and how to test scheduled actions without waiting for the clock.
What ir.cron is#
ir.cron is an Odoo model. Records in ir.cron are persisted in the database. The Odoo server process has a background thread that wakes up regularly, queries ir.cron for due records, and executes them. This is not an OS-level cron - it is entirely managed inside the Odoo process.
The scheduler thread wakes up by default every minute (configurable via ir.config_parameter key base_setup.default_cron_frequency). This is the minimum resolution: scheduled actions cannot run more frequently than once per minute.
Defining a scheduled action in XML#
<record id="cron_sync_partner_data" model="ir.cron">
<field name="name">Sync: partner external data</field>
<field name="model_id" ref="base.model_res_partner"/>
<field name="state">code</field>
<field name="code">model._cron_sync_partner_data()</field>
<field name="interval_number">1</field>
<field name="interval_type">days</field>
<field name="numbercall">-1</field>
<field name="active" eval="True"/>
<field name="priority">5</field>
</record>Key fields:
| Field | What it does |
|---|---|
model_id | The Odoo model on which the method is called |
state | code (execute a Python snippet) or object_write (write a field value to records) |
code | The Python snippet when state='code'. The model variable is the recordset. |
interval_number + interval_type | Interval size and unit: minutes, hours, days, weeks, months |
numbercall | How many times to run. -1 means indefinitely. |
active | Set False to disable. |
priority | Lower number = higher priority. Default is 5. |
Writing the cron method on your model#
The Python snippet in code runs with model bound to the model class (not a filtered recordset). Define the actual logic as a method on the model:
class ResPartner(models.Model):
_inherit = 'res.partner'
def _cron_sync_partner_data(self):
partners = self.search([('is_company', '=', True), ('active', '=', True)])
for partner in partners:
try:
self._sync_one_partner(partner)
except Exception as e:
_logger.error("Sync failed for partner %s: %s", partner.id, e)
# No explicit commit - Odoo commits after each cron run automaticallyTwo naming conventions matter: prefix cron methods with _cron_ to signal they are scheduled entry points, and prefix with an underscore to mark them as internal (not callable via XML-RPC).
Error handling in cron methods#
Odoo wraps each cron execution in a transaction. If your method raises an unhandled exception, Odoo:
- Rolls back the transaction for that cron run
- Logs the traceback to the server log
- Records the failure in the
ir.cronrecord (fieldlastcall) - Does NOT disable the cron by default (it will try again next interval)
If the cron raises 5 consecutive exceptions, Odoo sets active = False on the record and sends an email to the technical alert address. This means a broken cron silently stops after 5 failures - check the cron's active state if a scheduled job stops appearing to run.
The safest pattern for crons that process many records is to catch exceptions per-record and log them, rather than letting a single failure abort the entire batch:
def _cron_process_queue(self):
records = self.search([('state', '=', 'pending')])
for rec in records:
try:
rec.with_context(from_cron=True)._process()
except Exception:
_logger.exception("Processing failed for record %d", rec.id)
rec.write({'state': 'error'})Multi-company crons#
In a multi-company setup, crons run under the company of the record defined in ir.cron. If you need to process records across all companies, you have two options.
Option 1 - sudo with all companies in context:
def _cron_sync_all_companies(self):
all_companies = self.env['res.company'].sudo().search([])
for company in all_companies:
self.with_company(company).sudo()._sync_company_records()Option 2 - use company_ids context:
def _cron_sync_all_companies(self):
self.with_context(
allowed_company_ids=self.env['res.company'].sudo().search([]).ids
)._sync_records()Option 1 is safer because it processes each company in its own loop and catches failures per company. Option 2 is faster but gives you a single transaction for all companies - one failure rolls back everything.
Be aware that ir.rules that filter by company_id will apply to cron runs. If your cron is defined on company A and you search without sudo(), you will only see company A's records. Use sudo() carefully and only when you genuinely need cross-company access.
Execution priority and the cron queue#
The scheduler picks up crons in order of nextcall (the next scheduled execution time). Among crons due at the same time, lower priority numbers run first. In a heavily loaded instance, low-priority crons can be delayed if high-priority crons take a long time.
Cron runs are serialized - Odoo does not run two instances of the same cron in parallel. If a cron run takes longer than its interval, the next run is skipped rather than overlapping. This is the correct behavior for most background tasks. If you need parallel processing, split work into smaller crons or use BullMQ-style queue workers outside the ORM.
Triggering a cron manually (for testing)#
From the Odoo Settings → Technical → Scheduled Actions menu, you can trigger a cron manually by clicking "Run Manually." This is useful for development but requires Technical menu access.
From code, you can call the method directly:
# In a test or during development
self.env['res.partner']._cron_sync_partner_data()Or trigger the cron record itself:
cron = self.env.ref('my_module.cron_sync_partner_data')
cron.method_direct_trigger()method_direct_trigger() is defined on ir.cron and runs the cron immediately regardless of its scheduled time.
Testing crons in unit tests#
class TestPartnerCron(TransactionCase):
def test_cron_processes_pending_partners(self):
partner = self.env['res.partner'].create({
'name': 'Test Corp',
'is_company': True,
})
# Simulate cron run directly
self.env['res.partner']._cron_sync_partner_data()
# Assert on the expected state change
self.assertEqual(partner.sync_state, 'done')Do not test scheduling behavior (whether the cron fires on time) - that is framework responsibility. Test that the method produces the expected side effects when called.
Common mistakes#
Calling self.env.cr.commit() inside a cron method. Odoo manages cron transactions. Committing manually inside the method creates partial-success states if the rest of the method fails. Never commit inside a cron unless you have a very specific reason (e.g., large batch processing where you intentionally want intermediate commits).
Assuming the cron user has full access. Crons run as the user defined on the ir.cron record (usually OdooBot). That user may not have access to all records. Use sudo() when the cron genuinely needs elevated access, and document why.
Long-running crons without progress logging. If a cron takes more than a few seconds, log progress at regular intervals. This is the only way to diagnose stalls without attaching a debugger.
Using datetime.now() instead of fields.Datetime.now(). Odoo stores datetimes in UTC. datetime.now() returns local time. Always use fields.Datetime.now() or datetime.utcnow() in ORM code.
ERPeek can answer questions like "what scheduled actions are defined in this codebase?" or "which model methods are called by crons?" across any Odoo version. See the contact page for details.

