Odoo Development
Odoo Scheduled Actions and Cron Jobs
A scheduled action is a server action Odoo runs on a timer. The framework part is simple. The hard part is writing a job that behaves correctly when it runs late, runs twice, or runs against ten thousand records instead of the ten you tested with.
Part of our guide to Odoo Development
Defining one
A scheduled action is an ir.cron record, so it is data rather than code:
Expire old quotations code model._cron_expire_quotations() 1 days
Inside code, model is the model named by model_id, so the convention is a one-line call into a real method. Put the logic in Python where it can be read, tested and version controlled. A cron record whose code field contains twenty lines of business logic is code hiding in the database.
Then the method:
class SaleOrder(models.Model):
_inherit = "sale.order"
def _cron_expire_quotations(self):
stale = self.search([
("state", "=", "draft"),
("validity_date", "<", fields.Date.context_today(self)),
], limit=500)
stale.action_cancel()What changed between versions
ir.cron is not stable across versions, which matters if you maintain one module for several.
Odoo 17 has numbercall and doall. numbercall limited how many times a job would run before deactivating; doall decided whether missed executions were caught up.
Odoo 19 has neither. Both fields are gone. It adds failure_count and first_failure_date instead, so the model now tracks consecutive failures rather than counting scheduled runs.
A cron record carrying numbercall will fail to load on 19, and one relying on doall semantics has no equivalent. This is a specific thing to check when down-porting or upgrading a module, because the failure is at data load and points at your XML rather than at the version difference.
Write jobs that tolerate running twice
This is the part that separates a scheduled action that works from one that causes an incident.
A job can run late because the server was down. It can run again because someone triggered it manually. It can overlap with itself if the previous run has not finished. Assume all three.
Select by state, not by time. A job that processes "records created yesterday" reprocesses them if it runs twice, and misses them entirely if it runs two days late. A job that processes "records where processed is False" is correct in both cases.
Mark work as done in the same transaction as doing it. If sending an email and setting sent = True are separated, a failure between them sends the email again next run.
Do not assume the previous run finished. Two overlapping runs selecting the same records will both process them.
Batch, and let it come back
Scheduled actions that process everything they find will eventually meet a backlog larger than they can handle, and then they time out, roll back, and fail identically forever.
Use a limit, and let the next run continue:
def _cron_process_queue(self):
batch = self.search([("state", "=", "pending")], limit=200)
for record in batch:
record._process()Two hundred records every hour clears a backlog steadily. Ten thousand in one run either succeeds slowly, holding locks the whole time, or fails and achieves nothing.
Commit between batches only when you understand what you are doing to the transaction. Usually the better answer is a smaller limit and a shorter interval.
Handle failures deliberately
An unhandled exception rolls back the whole run, including work already done in that transaction.
If one bad record should not stop the batch, catch per record and continue, and log enough to find it later:
for record in batch:
try:
record._process()
except Exception:
_logger.exception("Failed processing %s", record)
record.error_flag = TrueThat pattern has a cost: the flag write is in the same transaction being rolled back if the outer run fails, so it is not a substitute for a queue when reliability really matters.
Operational notes
Cron needs workers. In a multi-process deployment, scheduled actions run in dedicated cron workers. A server configured with none runs no jobs while appearing entirely healthy, which is a memorable afternoon.
Timing is approximate. interval_number and interval_type set a frequency, not a schedule. A job set to daily runs roughly daily from its last run, not at a fixed hour.
Restored databases run their crons. A copy of production starts executing every scheduled action it inherited, against real integrations and real email configuration. Disable them on any restored copy before starting it, which is one of the upgrade mistakes worth having in the runbook.
Test by calling the method, not by waiting. The logic is an ordinary method, so test it directly. The cron record only decides when it is called.
FAQ
Questions, answered.
Why is my Odoo scheduled action not running?
Check that it is active, that nextcall is not in the future, and that the server is actually running cron workers. On a multi-worker deployment cron needs at least one dedicated worker, and a server started without them runs no scheduled actions at all while looking entirely healthy.
Did ir.cron change in Odoo 19?
Yes. The numbercall and doall fields that existed in Odoo 17 are gone in Odoo 19, and failure_count and first_failure_date were added, so the model now tracks repeated failures. A module carrying those old fields in its cron data will not load unchanged.
How do I stop a scheduled action running twice on the same records?
Make the job select its own work by state rather than by time, and mark records as processed as part of the same transaction. A job that filters on a date window will reprocess if it runs late or twice; one that filters on a flag will not.
Keep reading
More on Odoo Development.
The full guide, plus the other articles in this cluster.
With CODEerts
Need something built properly?
We are certified Odoo partners. We build custom Odoo modules that extend the framework rather than fight it, and we publish our own apps on the Odoo Store.
See how we can helpBook a callReady to make Odoo work the way your business does?
Book a free callCODEerts is a team of certified Odoo partners and full-stack engineers. We implement, customise and support Odoo ERP, then build the software around it.