Overdue rental reminders, nightly currency rate updates, clearing out old draft records: anything that should happen without a user clicking a button usually ends up as a scheduled action. In developer mode they’re listed as Scheduled Actions in the Technical settings menu, and each one is an ir.cron record.
What a cron record is made of
An ir.cron is built on top of a server action. It uses delegation inheritance on ir.actions.server, which is why the cron itself has model_id, state and code fields even though they live on the server action. The fields you set most often:
- name, shown in the list and in log lines.
- model_id, the model whose method runs. In the code,
modelis an empty recordset of it. - state set to
code, and code holding the call, such asmodel._cron_flag_overdue(). - interval_number and interval_type, where the type is one of minutes, hours, days, weeks or months.
- nextcall, the next planned run. It defaults to now, so a new cron runs soon after install.
- user_id, the user the job runs as, and priority, where 0 is highest.
Defining one in a module
The method goes on the model:
from odoo import api, fields, models
class EquipmentRental(models.Model):
_name = "equipment.rental"
_description = "Equipment Rental"
# state, return_date and is_overdue fields defined here
@api.model
def _cron_flag_overdue(self):
overdue = self.search([
("state", "=", "out"),
("return_date", "<", fields.Date.today()),
("is_overdue", "=", False),
])
overdue.write({"is_overdue": True})
And the record goes in an XML data file listed in the manifest. This version is for Odoo 18 and 19:
<odoo noupdate="1">
<record id="ir_cron_flag_overdue_rentals" model="ir.cron">
<field name="name">Equipment Rental: flag overdue returns</field>
<field name="model_id" ref="model_equipment_rental"/>
<field name="state">code</field>
<field name="code">model._cron_flag_overdue()</field>
<field name="interval_number">1</field>
<field name="interval_type">hours</field>
</record>
</odoo>
noupdate="1" means an admin who changes the interval won’t have it reset on the next module update.
What changed in Odoo 18
Odoo 17 still has numbercall and doall. numbercall defaults to 1, meaning the job runs once and then deactivates itself, so 17 modules need <field name="numbercall">-1</field> for a job that repeats. doall controlled whether missed runs were replayed after downtime.
Both fields are gone from 18.0 onward. Leave numbercall in a record you port from 17 and the module won’t install, because the field doesn’t exist.
Odoo 18 also added failure tracking. According to the docs, a job that hits an error or a timeout three consecutive times is considered failed. After five consecutive failures spread over at least seven days, Odoo deactivates the job and notifies the admin.
Long jobs are expected to work in batches. Odoo 18 has _notify_progress(done=..., remaining=...) for this. Odoo 19 replaces it with _commit_progress(), which commits each batch and tells your loop how much time it has left, and marks the old method deprecated.
Where cron jobs go wrong
Copying cron XML across versions. A 17 record with numbercall breaks the install on 18. An 18 record without it installs fine on 17, runs once and switches itself off. Check the version before you paste a record from a tutorial.
One huge transaction. A job that processes 200,000 records in one call holds a worker and can hit the time limit. Batch it.
Calling the method directly to test it. The docs recommend method_direct_trigger() so the job runs the way the scheduler would run it.
Wondering why it never fired on staging. On Odoo.sh, staging databases are neutralized and scheduled actions are disabled there until you trigger or re-enable them.
Because the field list changed between 17 and 18, cron XML has to be written for the exact version the addon targets, which is one reason erpfly asks for your Odoo version before it writes anything. See Odoo module development for the rest of what’s in the addon, or our step-by-step module guide for where data files sit in the manifest.