GuideOdoo AutomationMarch 13, 2026

Scheduled Actions in Odoo 19:
Automate Recurring Tasks Reliably

INTRODUCTION

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.

01

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.

FieldTypePurposeDefault
model_idMany2oneThe model whose method will be calledRequired
codeTextPython code or model.method_name() call to executeRequired
interval_numberIntegerHow many units between executions1
interval_typeSelectionminutes, hours, days, weeks, monthsmonths
nextcallDatetimeNext scheduled execution time (UTC)now()
numbercallIntegerRemaining executions; -1 = infinite-1
doallBooleanIf True, execute missed runs on server restartFalse
priorityIntegerExecution order (lower = first); default 55
activeBooleanEnables/disables the cron without deleting itTrue

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.

How the Cron Worker Selects Jobs

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.

02

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 — my_module/data/ir_cron_data.xml
<?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_module resets the cron to your XML defaults.
  • state="code" — tells Odoo to execute the code field as Python. The alternative is state="object", but code is the standard pattern in Odoo 19.
  • model.method_name() — in the code field, model is a reference to the model class (not a recordset). Your method receives self as an empty recordset of that model.
  • nextcall with 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.
Don't Forget __manifest__.py

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.

03

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.

Python — my_module/models/sale_order.py
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.model decorator — cron methods receive self as an empty recordset. The method must search() for its own records. Never assume self contains data.
  • Descriptive docstring — include which ir.cron XML ID calls this method. Six months from now, someone will see this method and wonder "what triggers this?" The docstring tells them.
cr.commit() — Handle With Care

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.

04

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:

Python — Cursor-based batch processing with limit
@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 None

Performance techniques demonstrated above:

  • search() with limit and offset — 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 uses timeout=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_qty check avoids writing the same value back, which would trigger unnecessary write audit logs and recomputation of dependent computed fields.

Common Cron Patterns

PatternIntervaldoallExample
CleanupDailyFalseDelete expired sessions, cancel stale drafts, purge old logs
Reminder/NotificationDaily/WeeklyFalseOverdue invoice emails, upcoming renewal alerts, SLA breach warnings
External SyncHourlyTrueWMS inventory sync, marketplace order import, CRM lead sync
ComputationNightlyFalseRecompute KPIs, rebuild search indexes, update currency rates
EscalationHourlyFalseAuto-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:

ParameterDefaultRecommendedWhy
max_cron_threads22 (min)One thread for long-running syncs, one for quick jobs. Set to 0 on worker nodes that shouldn't run crons.
limit_time_cpu60600 for cron workersDefault 60s CPU limit kills batch-processing crons that handle thousands of records. Increase for servers running heavy crons.
limit_time_real1201800 for cron workersWall-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_hard26843545604294967296 (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.

Cron Workers in Multi-Process Mode

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.

05

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:

SQL — Check cron status and find stuck jobs
-- 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 ERROR level with the full traceback.
  • Check numbercall — if it reached 0, the cron auto-deactivated after its final run. This is the #1 cause of "my cron stopped" when someone set numbercall=1 during testing and forgot to change it back.
  • Check the worker — run ps aux | grep odoo and verify the cron worker process exists. If max_cron_threads = 0 in the config, no crons will ever execute.
Shell — Monitor cron execution from the server
# 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()
"
External Monitoring Is Non-Negotiable

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.

06

4 Cron Job Mistakes That Silently Break Odoo Automation

1

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.

Our Fix

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.

2

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.

Our Fix

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.

3

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.

Our Fix

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.

4

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.

Our Fix

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.

BUSINESS ROI

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:

40 hrs/moManual Tasks Eliminated

Expiration cleanup, reminder emails, inventory syncs, and report generation that used to require daily human intervention now run unattended.

99.5%Cron Completion Rate

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.

< 5 minIssue Detection Time

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.

SEO NOTES

Optimization Metadata

Meta Desc

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.

H2 Keywords

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"

Your Cron Jobs Should Run Like a Clock, Not a Lottery

A scheduled action that works 95% of the time is not reliable — it's a time bomb. The 5% failure rate compounds silently: unsynced inventory, unsent reminders, uncleaned data, unsurfaced exceptions. By the time someone notices, the remediation involves manual data fixes, customer apologies, and emergency weekend work.

If your Odoo 19 cron jobs are unreliable, intermittently failing, or you simply don't know whether they're running, we can help. We audit existing scheduled actions, implement proper error handling and batch processing, set up external monitoring, and build the cron infrastructure that runs silently and correctly — day after day, month after month.

Book a Free Automation Review