Your Scheduled Action Ran Once, Then Silently Stopped Three Months Ago
Every Odoo implementation eventually needs background automation: nightly inventory syncs, weekly overdue-invoice reminders, daily cleanup of expired quotations, hourly API pulls from a third-party warehouse. The mechanism for all of these is ir.cron — Odoo's built-in scheduled action framework. It works. Until it doesn't.
The failure mode is always the same: a cron job raises an unhandled exception on one record, the entire method aborts, the nextcall field advances anyway, and nobody notices for weeks because there's no alerting, no retry, and no dashboard showing cron health. The data that should have been processed sits in limbo — orders unsynced, reminders unsent, cleanup undone.
This guide covers everything you need to build reliable, production-grade scheduled actions in Odoo 19: the ir.cron model internals, XML data record configuration, interval types, numbercall vs doall, method implementation with proper error handling, batch processing patterns, performance optimization, monitoring, and debugging stuck crons. Every pattern is production-tested across our client portfolio.
Understanding the ir.cron Model: How Odoo 19 Executes Scheduled Actions
Every scheduled action in Odoo is a record in the ir.cron model (which inherits from ir.actions.server). The Odoo cron worker — a dedicated thread in multi-worker mode or a periodic check in single-worker mode — queries this table every 60 seconds, finds records where nextcall <= now() and active = True, and executes them sequentially.
| Field | Type | Purpose | Default |
|---|---|---|---|
model_id | Many2one | The model whose method will be called | Required |
code | Text | Python code or model.method_name() call to execute | Required |
interval_number | Integer | How many units between executions | 1 |
interval_type | Selection | minutes, hours, days, weeks, months | months |
nextcall | Datetime | Next scheduled execution time (UTC) | now() |
numbercall | Integer | Remaining executions; -1 = infinite | -1 |
doall | Boolean | If True, execute missed runs on server restart | False |
priority | Integer | Execution order (lower = first); default 5 | 5 |
active | Boolean | Enables/disables the cron without deleting it | True |
The critical distinction: numbercall vs doall. Setting numbercall=-1 means "run forever." Setting doall=True means "if the server was down for 3 days and this cron should have run 3 times, run it 3 times when the server comes back." For idempotent operations (cleanup, sync), doall=True is safe. For operations that send emails or create records, doall=True can cause duplicate sends or double-creation — use doall=False and accept that missed runs are skipped.
In Odoo 19, the cron worker uses SELECT ... FOR UPDATE SKIP LOCKED to pick the next job. This means in a multi-worker deployment, two workers will never execute the same cron simultaneously. However, it also means a long-running cron blocks other crons from running if you only have one cron worker (the default). Always set max_cron_threads = 2 in production so one stuck job doesn't freeze all scheduled actions.
Declaring Scheduled Actions in XML: The Complete ir.cron Data Record Pattern
Cron jobs are declared as XML data records in your module's data/ directory. This is the canonical pattern for Odoo 19:
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data noupdate="1">
<!-- ── Daily: Clean up expired draft quotations ── -->
<record id="ir_cron_cleanup_expired_quotations" model="ir.cron">
<field name="name">Sales: Clean Up Expired Quotations</field>
<field name="model_id" ref="sale.model_sale_order"/>
<field name="state">code</field>
<field name="code">model._cron_cleanup_expired_quotations()</field>
<field name="interval_number">1</field>
<field name="interval_type">days</field>
<field name="numbercall">-1</field>
<field name="doall" eval="False"/>
<field name="priority">10</field>
<field name="nextcall" eval="(DateTime.now() + timedelta(days=1)).strftime('%Y-%m-%d 02:00:00')"/>
</record>
<!-- ── Hourly: Sync inventory from external WMS ── -->
<record id="ir_cron_sync_wms_inventory" model="ir.cron">
<field name="name">Inventory: Sync from External WMS</field>
<field name="model_id" ref="stock.model_stock_quant"/>
<field name="state">code</field>
<field name="code">model._cron_sync_wms_inventory()</field>
<field name="interval_number">1</field>
<field name="interval_type">hours</field>
<field name="numbercall">-1</field>
<field name="doall" eval="True"/>
<field name="priority">5</field>
</record>
<!-- ── Weekly: Send overdue invoice reminders ── -->
<record id="ir_cron_overdue_invoice_reminder" model="ir.cron">
<field name="name">Accounting: Overdue Invoice Reminders</field>
<field name="model_id" ref="account.model_account_move"/>
<field name="state">code</field>
<field name="code">model._cron_send_overdue_reminders()</field>
<field name="interval_number">1</field>
<field name="interval_type">weeks</field>
<field name="numbercall">-1</field>
<field name="doall" eval="False"/>
<field name="priority">15</field>
<field name="nextcall" eval="(DateTime.now() + timedelta(days=(7 - DateTime.now().weekday()) % 7)).strftime('%Y-%m-%d 06:00:00')"/>
</record>
</data>
</odoo>Key details that matter:
noupdate="1"— prevents module upgrades from overwriting user changes to the cron (e.g., if they changed the interval from daily to hourly). Without this, every-u my_moduleresets the cron to your XML defaults.state="code"— tells Odoo to execute thecodefield as Python. The alternative isstate="object", butcodeis the standard pattern in Odoo 19.model.method_name()— in thecodefield,modelis a reference to the model class (not a recordset). Your method receivesselfas an empty recordset of that model.nextcallwith eval — sets the first execution time. For daily crons, schedule at 02:00 UTC to avoid peak hours. For weekly crons, calculate the next Monday.priority— lower numbers execute first. Use 5 for critical syncs, 10 for cleanup, 15+ for notifications. This matters when multiple crons are due simultaneously.
Your cron XML file must be listed in the 'data' key of __manifest__.py, not 'demo'. If it's in 'demo', the cron only exists when demo data is loaded — which is never in production. We've seen teams debug a "missing cron" for hours before realizing it was in the wrong manifest key.
Implementing Cron Methods: Error Handling, Logging, and Transaction Safety
The cron XML record calls a method. Here's where most teams get it wrong: they write a method that works on the happy path and explodes on the first exception, taking down the entire cron run. The pattern below handles errors per-record, logs everything, and commits in batches to avoid holding a database lock for 30 minutes.
import logging
from datetime import timedelta
from odoo import api, fields, models
from odoo.exceptions import UserError
_logger = logging.getLogger(__name__)
BATCH_SIZE = 100 # Records per commit batch
class SaleOrder(models.Model):
_inherit = 'sale.order'
expiration_date = fields.Date(
string="Quotation Expiration",
help="Date after which draft quotation is auto-cancelled.",
)
@api.model
def _cron_cleanup_expired_quotations(self):
"""Cancel draft quotations past their expiration date.
Called by ir.cron record: ir_cron_cleanup_expired_quotations
Runs daily at 02:00 UTC. Processes in batches of 100.
"""
today = fields.Date.today()
domain = [
('state', 'in', ['draft', 'sent']),
('expiration_date', '!=', False),
('expiration_date', '<', today),
]
expired_orders = self.search(domain, order='id asc')
if not expired_orders:
_logger.info("Cron cleanup: No expired quotations found.")
return
_logger.info(
"Cron cleanup: Found %d expired quotations to cancel.",
len(expired_orders),
)
total_ok = 0
total_fail = 0
for batch in self._split_batches(expired_orders, BATCH_SIZE):
for order in batch:
try:
order.action_cancel()
order.message_post(
body="Auto-cancelled: quotation expired on %s."
% order.expiration_date,
)
total_ok += 1
except Exception:
total_fail += 1
_logger.exception(
"Cron cleanup: Failed to cancel SO %s (id=%d).",
order.name, order.id,
)
# Commit after each batch to release DB locks
self.env.cr.commit()
_logger.info(
"Cron cleanup complete: %d cancelled, %d failed.",
total_ok, total_fail,
)
@api.model
def _split_batches(self, recordset, size):
"""Yield successive batches from a recordset."""
for i in range(0, len(recordset), size):
yield recordset[i:i + size]The critical patterns in this implementation:
- Per-record try/except — if order #47 fails, orders #48 through #500 still get processed. Without this, one bad record kills the entire run.
- Structured logging — every cron logs its start, count, per-failure details, and final summary. When a cron "isn't working," the first thing you check is
grep "Cron cleanup" /var/log/odoo/odoo.log. self.env.cr.commit()per batch — without explicit commits, the entire cron method runs in a single transaction. If it processes 10,000 records over 20 minutes, the database holds row locks the entire time. Batch commits release locks progressively.@api.modeldecorator — cron methods receiveselfas an empty recordset. The method mustsearch()for its own records. Never assumeselfcontains data.- Descriptive docstring — include which
ir.cronXML ID calls this method. Six months from now, someone will see this method and wonder "what triggers this?" The docstring tells them.
Calling self.env.cr.commit() inside a cron method is necessary for batch processing but dangerous everywhere else. It commits partial work — if the cron crashes halfway, the first half is already committed and won't roll back. This is acceptable for idempotent operations (cancelling already-expired orders is safe to re-run) but dangerous for operations that must be all-or-nothing (like financial postings). For atomic operations, remove the mid-method commit and accept the longer lock.
Batch Processing and Performance Optimization for Odoo 19 Cron Jobs
The naive approach — self.search([]).write({{...}}) — works for 100 records. At 50,000 records, it OOMs the worker, locks the table for 10 minutes, and blocks all other crons. Here are the patterns we use for high-volume cron jobs:
@api.model
def _cron_sync_wms_inventory(self):
"""Sync inventory quantities from external WMS API.
Processes records in batches using search with limit/offset
to avoid loading 50k+ records into memory at once.
"""
BATCH_SIZE = 200
offset = 0
total_synced = 0
while True:
quants = self.search(
[('location_id.usage', '=', 'internal')],
limit=BATCH_SIZE,
offset=offset,
order='id asc',
)
if not quants:
break
for quant in quants:
try:
external_qty = self._fetch_wms_qty(
quant.product_id, quant.location_id
)
if external_qty is not None and quant.quantity != external_qty:
quant.sudo().write({{'quantity': external_qty}})
total_synced += 1
except Exception:
_logger.exception(
"WMS sync failed for quant id=%d, product=%s",
quant.id, quant.product_id.display_name,
)
self.env.cr.commit()
# Invalidate cache to free memory after each batch
self.env.invalidate_all()
offset += BATCH_SIZE
_logger.info("WMS sync complete: %d quants updated.", total_synced)
def _fetch_wms_qty(self, product, location):
"""Call external WMS API for current quantity.
Returns float quantity or None if API unavailable.
Timeout: 5 seconds per call to avoid blocking the cron.
"""
import requests
try:
resp = requests.get(
f"https://wms.example.com/api/v1/stock",
params={{
'sku': product.default_code,
'location': location.name,
}},
timeout=5,
)
resp.raise_for_status()
return resp.json().get('quantity')
except requests.RequestException:
_logger.warning(
"WMS API unreachable for SKU %s", product.default_code
)
return NonePerformance techniques demonstrated above:
search()withlimitandoffset— loads 200 records at a time instead of the full table. Memory usage stays flat regardless of total record count.self.env.invalidate_all()— clears the ORM's in-memory cache after each batch. Without this, the cache grows linearly and can consume gigabytes on large datasets.- External API timeout — the
requests.get()call usestimeout=5. A single hung API call without a timeout will block the cron worker indefinitely, freezing all other scheduled actions. - Idempotent writes — the
if quant.quantity != external_qtycheck avoids writing the same value back, which would trigger unnecessarywriteaudit logs and recomputation of dependent computed fields.
Common Cron Patterns
| Pattern | Interval | doall | Example |
|---|---|---|---|
| Cleanup | Daily | False | Delete expired sessions, cancel stale drafts, purge old logs |
| Reminder/Notification | Daily/Weekly | False | Overdue invoice emails, upcoming renewal alerts, SLA breach warnings |
| External Sync | Hourly | True | WMS inventory sync, marketplace order import, CRM lead sync |
| Computation | Nightly | False | Recompute KPIs, rebuild search indexes, update currency rates |
| Escalation | Hourly | False | Auto-assign unhandled helpdesk tickets, escalate stale tasks |
Odoo Configuration for Cron Performance
Your odoo.conf settings directly impact cron reliability. These are the cron-relevant parameters most teams get wrong:
| Parameter | Default | Recommended | Why |
|---|---|---|---|
max_cron_threads | 2 | 2 (min) | One thread for long-running syncs, one for quick jobs. Set to 0 on worker nodes that shouldn't run crons. |
limit_time_cpu | 60 | 600 for cron workers | Default 60s CPU limit kills batch-processing crons that handle thousands of records. Increase for servers running heavy crons. |
limit_time_real | 120 | 1800 for cron workers | Wall-clock timeout. A cron that calls an external API for each of 5,000 records easily exceeds 120s. Set to 30 minutes minimum. |
limit_memory_hard | 2684354560 | 4294967296 (4GB) | Crons processing large datasets load records into memory. The default 2.5GB limit triggers OOM kills on batch operations above ~50k records. |
A common production pattern: run two Odoo instances behind a load balancer — one handles HTTP requests with max_cron_threads = 0 and tight resource limits, the other runs crons with workers = 0 (single-process mode) and generous time/memory limits. This prevents a runaway cron from consuming HTTP worker resources and degrading the user experience.
When Odoo runs in multi-process mode (workers > 0), the cron threads run in a separate child process, not inside the HTTP worker processes. This means limit_time_cpu and limit_time_real apply independently to the cron process. If your cron hits the CPU limit, the cron worker process is killed and respawned — but the interrupted cron's transaction is rolled back (unless you used cr.commit() mid-method), and the cron's nextcall is not advanced. It will retry on the next scheduler tick.
Monitoring Cron Health and Debugging Stuck Scheduled Actions in Odoo 19
The most common support ticket we see: "our scheduled action stopped running and we don't know when." Odoo provides no built-in cron monitoring dashboard. Here's how to build observability into your cron jobs and debug the inevitable stuck cron:
-- List all active crons with their last and next execution
SELECT
c.id,
c.cron_name AS name,
c.interval_number || ' ' || c.interval_type AS frequency,
c.nextcall,
c.lastcall,
c.priority,
c.numbercall,
CASE
WHEN c.nextcall < NOW() - INTERVAL '2 hours'
THEN 'STUCK / OVERDUE'
ELSE 'OK'
END AS status
FROM ir_cron c
WHERE c.active = TRUE
ORDER BY c.nextcall ASC;
-- Find crons that haven't run in over 24 hours
SELECT cron_name, nextcall, lastcall,
NOW() - lastcall AS time_since_last_run
FROM ir_cron
WHERE active = TRUE
AND lastcall < NOW() - INTERVAL '24 hours'
ORDER BY lastcall ASC;
-- Check if a cron is currently locked (running)
SELECT c.cron_name, l.granted, l.pid
FROM pg_locks l
JOIN ir_cron c ON c.id = CAST(l.objid AS INTEGER)
WHERE l.locktype = 'advisory';When a cron appears stuck, follow this diagnostic sequence:
- Check
nextcall— if it's in the past by more than 2x the interval, the cron either errored or is locked by a long-running execution. - Check
pg_locks— Odoo uses PostgreSQL advisory locks to prevent concurrent cron execution. If the lock is held, the cron is still running (or the worker that held it crashed without releasing). - Check the Odoo log — search for the cron name or the method name. Errors during cron execution are logged at
ERRORlevel with the full traceback. - Check
numbercall— if it reached0, the cron auto-deactivated after its final run. This is the #1 cause of "my cron stopped" when someone setnumbercall=1during testing and forgot to change it back. - Check the worker — run
ps aux | grep odooand verify the cron worker process exists. Ifmax_cron_threads = 0in the config, no crons will ever execute.
# Tail the Odoo log for cron activity in real-time
tail -f /var/log/odoo/odoo.log | grep -i "cron\|ir_cron\|scheduled"
# Check if the cron worker process is alive
ps aux | grep "[o]doo.*cron"
# Force-run a specific cron from the command line (debugging only)
# This bypasses the scheduler and runs the method directly
python3 -c "
import odoo
odoo.tools.config.parse_config(['--config=/etc/odoo/odoo.conf'])
with odoo.api.Environment.manage():
registry = odoo.registry('your_database')
with registry.cursor() as cr:
env = odoo.api.Environment(cr, odoo.SUPERUSER_ID, {})
env['sale.order']._cron_cleanup_expired_quotations()
cr.commit()
" Don't rely on Odoo to tell you its own crons are broken — that's asking the patient to diagnose themselves. Set up an external health check: a simple script (triggered by system cron or your monitoring tool) that queries the ir_cron table via PostgreSQL and alerts if any cron's nextcall is more than 2x its interval in the past. We use a 5-line Python script that posts to Slack when a cron appears stuck. It's caught issues within minutes that would otherwise have gone unnoticed for weeks.
4 Cron Job Mistakes That Silently Break Odoo Automation
Using doall=True on Email-Sending Crons
Your server goes down for a weekend. When it comes back Monday, the weekly "overdue invoice reminder" cron fires three times in rapid succession because doall=True replays missed executions. Your customers receive three identical reminder emails within seconds. Support tickets flood in. Someone explains it was a "system glitch." Trust erodes.
Use doall=False for any cron that sends emails, creates records, or triggers external webhooks. Missed runs are skipped, and the cron resumes on its normal schedule. For sync operations where catching up matters, use doall=True but ensure the method is idempotent — syncing the same inventory twice should produce the same result.
No Exception Handling — One Bad Record Kills the Entire Run
A cron iterates over 500 orders to check for expiration. Order #12 has a corrupted date_order field (null where it shouldn't be). The comparison raises TypeError. The entire cron method aborts. Orders #13 through #500 are never processed. The cron's nextcall advances to tomorrow. 488 orders that should have been cancelled today will wait until the bug is fixed — which could be weeks if nobody notices.
Wrap per-record logic in try/except. Log the exception with _logger.exception() (which includes the full traceback). Count successes and failures. Log the summary at the end. A cron that processes 498/500 records and logs 2 failures is infinitely better than one that processes 12/500 and silently stops.
Long-Running Crons Block the Entire Scheduler
With the default max_cron_threads = 1, Odoo runs crons sequentially on a single thread. A WMS sync that takes 45 minutes means no other cron runs for 45 minutes — not the email queue, not the currency rate update, not the subscription renewal. Users report "emails are delayed" and "currency rates are wrong," but the actual root cause is a slow inventory sync holding the cron lock.
Set max_cron_threads = 2 in odoo.conf so at least two crons can run concurrently. For truly long-running operations (data migration, mass recomputation), move them to a system-level cron (crontab) that calls the Odoo method via XML-RPC. This way the operation runs outside the Odoo scheduler entirely and can't block other crons.
Timezone Mismatch — Crons Run at the Wrong Business Hour
Odoo stores nextcall in UTC. Your daily "send morning report" cron is set to run at 08:00 — but you set it as 08:00 UTC, which is 03:00 EST. The report arrives in inboxes at 3 AM and gets buried under other emails. Or worse: daylight saving time shifts push it to 4 AM or 2 AM depending on the season, and the "8 AM report" drifts between local times throughout the year.
Always convert your desired local time to UTC when setting nextcall. Document the intended local time in the cron's name field: "Sales Report (daily 8AM ET)". For DST-sensitive crons, accept the 1-hour drift or implement a helper that recalculates nextcall in local time after each execution.
What Reliable Cron Jobs Save Your Odoo Operation
Cron jobs are invisible infrastructure. Nobody notices when they work. Everyone notices when they don't. The ROI isn't in building them — it's in building them reliably:
Expiration cleanup, reminder emails, inventory syncs, and report generation that used to require daily human intervention now run unattended.
Per-record error handling means one bad record doesn't kill the run. 498 out of 500 records process successfully even when 2 have data issues.
External monitoring alerts within minutes when a cron falls behind schedule, versus the default of "someone notices weeks later."
The hidden cost of unreliable crons is data drift. When an inventory sync silently stops, your Odoo stock levels diverge from the warehouse. Orders get confirmed against phantom inventory. Customers receive "in stock" confirmations for items that shipped three days ago. The cleanup is always more expensive than the prevention.
Optimization Metadata
Master ir.cron scheduled actions in Odoo 19. XML data records, interval types, numbercall vs doall, batch processing, error handling, monitoring, and debugging stuck cron jobs.
1. "Understanding the ir.cron Model: How Odoo 19 Executes Scheduled Actions"
2. "Declaring Scheduled Actions in XML: The Complete ir.cron Data Record Pattern"
3. "Implementing Cron Methods: Error Handling, Logging, and Transaction Safety"
4. "Batch Processing and Performance Optimization for Odoo 19 Cron Jobs"
5. "4 Cron Job Mistakes That Silently Break Odoo Automation"