📋 What You Will Learn
- How Odoo scheduled actions work internally
- Defining a scheduled action in an XML data file
- Writing the Python cron method with proper decorators
- Configuring interval, next execution time, and active state
- Manually triggering and debugging cron jobs
What are Scheduled Actions?
Odoo's scheduler is a background process that wakes up every minute (by default) and checks the ir.cron table for jobs whose nextcall timestamp has passed. For each due job, it calls the specified Python method on the specified model, running as the specified user, inside a fresh database transaction.
This is analogous to a Unix cron job, but defined entirely within Odoo's data layer rather than the operating system. This means scheduled actions are:
- Visible and configurable by administrators in the UI (Settings > Technical > Scheduled Actions)
- Deployable as part of your module — defined in an XML data file
- Runnable manually for testing without waiting for the schedule
- Multi-tenant aware — they run per-database
Creating a Scheduled Action (XML)
Define the scheduled action as an ir.cron record in a data file (typically under data/cron.xml). Use noupdate="1" so that administrators can change the schedule without your module overwriting it on upgrade:
<odoo>
<data noupdate="1">
<record id="ir_cron_library_overdue_notifications" model="ir.cron">
<field name="name">Library: Send Overdue Notifications</field>
<field name="model_id" ref="model_library_loan"/>
<field name="state">code</field>
<field name="code">model._cron_send_overdue_notifications()</field>
<field name="user_id" ref="base.user_root"/>
<field name="interval_number">1</field>
<field name="interval_type">days</field>
<field name="numbercall">-1</field> <!-- -1 = run indefinitely -->
<field name="active">True</field>
<field name="nextcall">2026-01-01 08:00:00</field>
</record>
<!-- A second cron — runs every hour -->
<record id="ir_cron_library_update_overdue_status" model="ir.cron">
<field name="name">Library: Mark Overdue Loans</field>
<field name="model_id" ref="model_library_loan"/>
<field name="state">code</field>
<field name="code">model._cron_update_overdue_status()</field>
<field name="user_id" ref="base.user_root"/>
<field name="interval_number">1</field>
<field name="interval_type">hours</field>
<field name="numbercall">-1</field>
<field name="active">True</field>
<field name="nextcall">2026-01-01 00:00:00</field>
</record>
</data>
</odoo>
| Field | Description |
|---|---|
model_id | The model whose method will be called (use ref="model_your_model_name") |
code | Python snippet to run — typically model._cron_method_name() |
user_id | Which user the job runs as — use base.user_root (Administrator) for system tasks |
interval_number | Numeric part of the interval (e.g. 1, 5, 30) |
interval_type | Unit: minutes, hours, days, weeks, months |
numbercall | How many times to run: -1 = unlimited, 1 = run once then disable |
nextcall | Datetime of the first/next execution (UTC) |
The Python Method
The cron method lives on the model specified in model_id. It must be decorated with @api.model (it runs without a specific recordset — it operates on the model class). The method should be defensive — handle exceptions internally and log progress:
from odoo import models, fields, api
import logging
from datetime import timedelta
_logger = logging.getLogger(__name__)
class LibraryLoan(models.Model):
_name = 'library.loan'
_description = 'Library Loan'
@api.model
def _cron_update_overdue_status(self):
"""Mark loans as overdue when their due date has passed.
Called hourly by the ir.cron scheduler.
"""
today = fields.Date.today()
_logger.info("Running overdue status update cron. Today: %s", today)
overdue_loans = self.search([
('state', '=', 'active'),
('due_date', '<', today),
])
if overdue_loans:
overdue_loans.write({'state': 'overdue'})
_logger.info("Marked %d loans as overdue", len(overdue_loans))
else:
_logger.info("No loans to mark as overdue")
@api.model
def _cron_send_overdue_notifications(self):
"""Send email notifications for overdue loans.
Called daily at 8:00 AM UTC.
"""
_logger.info("Running overdue notification cron")
overdue_loans = self.search([
('state', '=', 'overdue'),
('notification_sent', '=', False),
])
template = self.env.ref(
'library_management.email_template_overdue_notice',
raise_if_not_found=False,
)
if not template:
_logger.warning("Overdue notice email template not found. Skipping.")
return
sent_count = 0
for loan in overdue_loans:
try:
template.send_mail(loan.id, force_send=True)
loan.notification_sent = True
sent_count += 1
except Exception as exc:
# Don't let one failure abort all other notifications
_logger.error(
"Failed to send overdue notice for loan %d: %s",
loan.id, exc
)
_logger.info(
"Overdue notification cron complete. Sent: %d, Skipped: %d",
sent_count, len(overdue_loans) - sent_count
)
Interval and Next Execution
After each run, Odoo automatically advances nextcall by the configured interval. You can view and modify both fields in the UI at Settings > Technical > Automation > Scheduled Actions (developer mode required).
The nextcall field stores UTC time. If you want a job to run at "8 AM local time", you need to account for your timezone offset. For example, UTC+3 means 5:00 AM UTC for an 8:00 AM local run. Also note that noupdate="1" means your XML definition of nextcall is only used on first install — subsequent module upgrades won't reset the time.
Passing Arguments
The code field in the cron record runs in a Python sandbox with access to model, env, and datetime. You can pass arguments inline:
<!-- Passing arguments via the code field -->
<field name="code">model._cron_archive_old_loans(days_threshold=365)</field>
<!-- Using env for multi-company aware operations -->
<field name="code">
for company in env['res.company'].search([]):
env['library.loan'].with_company(company)._cron_update_overdue_status()
</field>
@api.model
def _cron_archive_old_loans(self, days_threshold: int = 365):
"""Archive loans older than days_threshold days."""
cutoff = fields.Date.today() - timedelta(days=days_threshold)
old_loans = self.search([
('state', 'in', ['returned', 'lost']),
('return_date', '<=', cutoff),
])
old_loans.write({'active': False})
_logger.info("Archived %d old loans (threshold: %d days)", len(old_loans), days_threshold)
Debugging Cron Jobs
Cron jobs run asynchronously without a user session, which makes debugging harder. Here are the key techniques:
Manual Trigger from the UI
In developer mode, go to Settings > Technical > Automation > Scheduled Actions, open your action, and click the "Run Manually" button. Any exception or log output will be shown immediately.
Call from Shell
# Odoo shell — test a cron method directly
./odoo-bin shell -d my_database --no-http
# Then in the Python shell:
# env['library.loan']._cron_send_overdue_notifications()
# env.cr.commit()
Logging
Always use _logger inside cron methods. Set the log level for your module to DEBUG in Settings > Technical > Logging to see all output. Odoo also records the last execution result (success/failure and traceback) in the ir.cron record itself.
import logging
_logger = logging.getLogger(__name__)
@api.model
def _cron_my_job(self):
_logger.info("Cron started: _cron_my_job")
# ... work ...
_logger.debug("Processed %d records", count)
_logger.info("Cron complete: _cron_my_job")
Summary
📋 Key Points
- Scheduled actions are
ir.cronrecords — define them in an XML data file withnoupdate="1" - The
codefield contains a Python snippet that callsmodel._cron_method_name() - Cron methods use
@api.modeland iterate over records found withself.search() interval_number+interval_typedefine the schedule;nextcallis always in UTC- Use
numbercall=-1for indefinitely repeating jobs - Always use
_loggerand wrap per-record logic in try/except so one failure doesn't stop the batch - Test by clicking "Run Manually" in the UI (developer mode) or calling the method from the Odoo shell
FAQ
The Odoo scheduler process checks every minute by default. This means the minimum practical schedule interval is 1 minute. The actual execution time may vary by up to one minute from the configured nextcall time. For jobs needing sub-minute precision, you would need an external cron or a different approach.
Odoo acquires a database advisory lock before running a cron job. If the previous execution is still running when the next trigger fires, the new trigger is skipped. This prevents overlapping executions of the same job. However, if a job consistently takes longer than its interval, it will never run at full frequency — you need to either optimise the job or increase the interval.
Yes. Set active=False on the ir.cron record in that database from the UI. Since you used noupdate="1" in the XML, module upgrades won't re-enable it. You can also programmatically deactivate it with env.ref('module.cron_id').write({'active': False}) in a post-install hook or migration script.
No — never call self.env.cr.commit() inside a module method. Odoo handles transaction commit/rollback at the framework level. If your cron processes many records and you want partial saves (so that a failure on record 900 doesn't roll back records 1–899), use self.env.cr.savepoint() context managers around each record's processing block.