Skip to Content

Three failure modes of Odoo crons (and why most alerting misses two of them)

Odoo crons fail in three distinct ways. The failure modes look different in the database, produce different symptoms in production, and - most importantly - need different queries to detect. Most alerting setups we've seen only catch one of the three.

This post walks through each mode with the actual SQL you can run against your Odoo database today, the Odoo source-code references that explain the behaviour, and the version-specific details that matter (Odoo 18 and 19 auto-deactivate failing crons; earlier versions behave differently). If your production Odoo runs any scheduled action that has downstream business impact - invoice reminders, mail queue flush, stock reordering, subscription renewals, calendar sync, custom integrations - you'll want to know how each mode manifests before you find out the expensive way.

Failure mode 1: Overdue - the cron that never fired

The simplest and most common failure. A cron's nextcall is in the past, the cron is still marked active = true, and yet nothing ran. Not because it failed. Because it was never picked up.

Reasons this happens in production:

  • The cron worker isn't actually running. Odoo's cron scheduler runs in a dedicated process (or thread, depending on your deployment mode). If it crashes, or was never started, or was stopped by a systemd unit change, no cron will run - even though the ir_cron records look perfectly healthy in the database.
  • The system was down when the cron should have fired. Odoo does not catch up on missed runs by default. If your instance was offline for a maintenance window that spanned a cron's interval, that cron's scheduled invocation is simply lost.
  • Interval configuration was changed. Someone set interval_number or interval_type to values that push nextcall far into the future, or set the interval to something the scheduler treats as never-eligible.
  • The cron worker is saturated by long-running crons ahead of this one. Odoo's cron queue is serial per worker; a single 40-minute cron blocks everything behind it. In production this is common on end-of-month reports or heavy sync jobs.

Here's the detection query:

-- Crons that are active but overdue by more than 2 hours
SELECT
    id,
    cron_name,
    nextcall,
    NOW() AT TIME ZONE 'UTC' - nextcall AS overdue_by,
    interval_number,
    interval_type
FROM ir_cron
WHERE active = true
  AND nextcall < (NOW() AT TIME ZONE 'UTC') - INTERVAL '2 hours'
ORDER BY overdue_by DESC;

The 2-hour buffer is a starting point. Tune it to something a bit larger than your longest cron's interval - anything shorter will produce false positives from crons that legitimately run less often than every 2 hours.

The vendor-PO incident we've written about at length in the Grafana post is a good example of the operational cost of this failure mode: a customised cron that pushed purchase orders to vendors every couple of hours went a full day without firing, and nobody knew until a vendor said they hadn't received the PO. Whether the underlying cause was "cron never picked up" (mode 1) or "cron ran but the send failed" (mode 2) matters diagnostically - the SQL you'd run differs - but the downstream impact is the same either way.

Failure mode 2: Silently failed - raised, logged, and moved on

The cron ran. It raised an exception during execution. Odoo's cron runner caught the exception, rolled back the transaction, wrote the traceback to the log, and advanced nextcall so the cron will run again next interval. From the outside, the ir_cron record looks completely healthy: recent nextcall, active = true, no obvious warning sign.

The downstream state, meanwhile, is wrong. The invoice reminders that were supposed to send didn't. The mail queue that was supposed to flush hasn't. The stock reorder that should have been placed wasn't. Every subsequent invocation raises the same exception (because the underlying condition - an expired credential, a broken integration, a bad customisation - hasn't changed), gets caught, logged, and forgotten.

In Odoo 18 and 19, this failure mode is eventually visible via the failure_count field on ir_cron:

-- Crons that have failed at least once recently
SELECT
    id,
    cron_name,
    active,
    failure_count,
    first_failure_date,
    nextcall
FROM ir_cron
WHERE failure_count > 0
ORDER BY failure_count DESC;

The counter increments on every failure and resets to zero on success - so a non-zero value means the last invocation failed. This field was added specifically to expose silent-failure state and back the auto-deactivation logic covered in the next section. In Odoo 17 and earlier, this field doesn't exist and there's no equivalent - you have to parse the log to detect silent failures, or infer them from downstream state.

An aside: the "cron ran successfully but did nothing" variant

There's a subtler cousin of failure mode 2 that's worth naming, because none of the queries in this post will catch it. A cron can fire on schedule, complete without raising a single exception, register as a successful invocation from Odoo's perspective (failure_count stays at zero, nothing lands in the log), and still do nothing useful - because a bug in the cron's own code caused it to silently no-op.

We had a custom cron at one client that was scheduled to run every 30 days and post a summary to Slack. It ran on schedule for over a year. Not one Slack message was ever posted. Two bugs stacked on top of each other: a date-arithmetic mistake that meant the code's "is today the 30th?" check never returned true (leap-year and short-month edge cases weren't handled), and a recordset-iteration mistake that would have short-circuited the send even if the date check had matched. Every invocation completed cleanly. Nothing was logged. The problem surfaced only when someone finally asked "hey, are we still getting those Slack updates?" - a year later.

The reason no cron-focused query catches this is that from Odoo's perspective the cron is fine. It ran, it didn't error, it moved on. The only defense against this variant is downstream monitoring - a check on the thing the cron is supposed to produce, not on the cron itself. In our Slack case: an alert if the target Slack channel goes N days without a message. Same principle applies to invoice reminders, backup jobs, sync integrations, anything with an observable output. Assume the cron might be lying.

Back to the mainline three-mode analysis.

Failure mode 3: Auto-deactivated - Odoo turned it off

This is the most severe mode and it's specific to Odoo 18 and 19. If a cron accumulates 5 consecutive failures AND at least 7 days have passed since the first failure, Odoo automatically sets active = false on the cron record and sends a notification to the system admin.

The relevant constants are at the top of odoo/addons/base/models/ir_cron.py:

MIN_FAILURE_COUNT_BEFORE_DEACTIVATION = 5
MIN_DELTA_BEFORE_DEACTIVATION = timedelta(days=7)
# crons must satisfy both minimum thresholds before deactivation

And the deactivation logic itself, in _update_failure_count():

if (
    failure_count >= MIN_FAILURE_COUNT_BEFORE_DEACTIVATION
    and fields.Datetime.context_timestamp(
        self, first_failure_date
    ) + MIN_DELTA_BEFORE_DEACTIVATION < now
):
    failure_count = 0
    first_failure_date = None
    active = False
    self._notify_admin(_(
        "Cron job %(name)s (%(id)s) has been deactivated "
        "after failing %(count)s times..."
    ))

This is defensible behaviour. Odoo is protecting its worker pool: a cron that has raised every time for a week and shows no sign of recovering is objectively broken, and re-running it every N minutes just wastes worker capacity without producing results. Better to stop it, tell someone, and wait for a human fix.

The operational consequence, though, is that a broken cron gets progressively less visible over time:

  1. Failure 1: cron runs, raises, gets logged. Downstream state wrong for one interval.
  2. Failures 2 through 4: same. Downstream state stays wrong.
  3. Failure 5 (and 7 days have passed since failure 1): cron deactivated. active = false. Admin gets one email. From this point on, the cron doesn't even try. Downstream state stays wrong indefinitely.

The admin email is the only signal, and in most Odoo deployments the admin address on the notification either goes to a shared mailbox nobody reads or gets caught by a spam filter.

Detection query for auto-deactivated crons:

-- Crons that Odoo has auto-deactivated (or that were manually disabled)
-- Look for records that were supposed to be active but aren't
SELECT
    c.id,
    c.cron_name,
    c.active,
    c.failure_count,
    c.first_failure_date,
    c.nextcall,
    c.write_date  -- the deactivation event will show here
FROM ir_cron c
WHERE c.active = false
ORDER BY c.write_date DESC;

The catch: this query doesn't distinguish between crons Odoo auto-deactivated and crons an admin turned off deliberately. Both set active = false. To identify auto-deactivations specifically, you have to correlate with the admin notification email - or run continuous monitoring that tracks failure_count as it climbs and flags the deactivation moment when it happens.

In our own client work, we haven't yet caught a cron in the fully-deactivated state. That's not surprising - the v18/19 auto-deactivation behaviour is relatively new, and the 5-failures-over-7-days threshold means a failing cron has to keep failing for at least a week before Odoo intervenes. But we've seen crons approaching the threshold with failure_count at three or four, which is exactly what a proactive monitoring layer should flag before it crosses the line.

Why traditional monitoring misses two of the three

If you're running Logz.io, Grafana with a Postgres exporter, or a log-tail alerting service, here's what each failure mode looks like from that angle:

Failure mode Visible in logs? Visible in generic Postgres metrics? Requires custom query?
Overdue (never fired) No - nothing was executed, nothing was logged No - ir_cron row still looks normal Yes - nextcall < NOW() - interval
Silently failed Yes - traceback in the Odoo log Only in Odoo 18/19 via failure_count > 0 Optional in v18/19, required in v17
Auto-deactivated (v18/19 only) Only the admin notification email Yes - active = false in ir_cron Yes - must distinguish auto-off from manual-off

The "visible in logs" column is why the average setup catches failure mode 2 and misses the other two. Log alerting fires when a keyword or level appears in output; mode 1 produces no output because nothing ran, and mode 3 produces one notification email that's easy to miss. You need an active query - something that looks at Odoo's own state, not at what Odoo happened to write to stdout - to see them at all.

Before we built Vigil, our own cron-health process at client work was entirely manual: a daily walk-through of Settings → Technical → Scheduled Actions, occasionally supplemented by a psql query if something looked off. That works until it doesn't - you catch things only if you remember to check, and you learn about failures the same way everyone else does, from a user complaint.

A single query to surface all three at once

If you want one query you can drop into a scheduled psql check and pipe into email or Slack, this is a reasonable starting point:

SELECT
    id,
    cron_name,
    CASE
        WHEN active = false THEN 'deactivated (auto or manual)'
        WHEN failure_count > 0 THEN 'silently failing'
        WHEN nextcall < (NOW() AT TIME ZONE 'UTC') - INTERVAL '2 hours' THEN 'overdue'
        ELSE 'healthy'
    END AS state,
    active,
    failure_count,
    first_failure_date,
    nextcall,
    interval_number || ' ' || interval_type AS interval
FROM ir_cron
WHERE
    active = false
    OR failure_count > 0
    OR nextcall < (NOW() AT TIME ZONE 'UTC') - INTERVAL '2 hours'
ORDER BY
    CASE state
        WHEN 'deactivated (auto or manual)' THEN 1
        WHEN 'silently failing' THEN 2
        WHEN 'overdue' THEN 3
        ELSE 4
    END,
    failure_count DESC;

Note the query uses the failure_count column, which requires Odoo 18 or 19. On Odoo 17 remove that clause and the corresponding CASE branch - you'll only catch two of the three modes.

Our honest opinion: Odoo should surface these three states in the standard Scheduled Actions view without a third-party module. The view already exists at Settings → Technical → Scheduled Actions. Adding two columns to it - a failure_count field and a visual indicator distinguishing auto-deactivated from manually-disabled records - would fix most of the visibility problem with almost no cost. It's a small ORM addition and a small view edit. That Odoo 18 and 19 ship the auto-deactivation logic but don't ship the corresponding UI signals is an ecosystem gap admins currently have to fill themselves, either by running the queries above or by installing something that runs them for you.

What we built

The queries above are how you check cron health manually. What we wanted, and eventually built, was a dashboard that runs those checks continuously, colour-codes the results by severity, and gives you a one-click drill-down to the offending record and its traceback. It's part of Odexalabs Vigil Suite, which is on the Odoo App Store for $129 one-time.

The cron detection specifically is a three-tier view: overdue crons in one panel, silently-failing crons (v18/19) in another, deactivated crons in a third - each with the number of failures, the first failure date, the next scheduled call, and a link straight to the ir.cron record. If you're on Odoo 17 the deactivated panel still works (for manually-disabled crons and for crons where the interval config drifted) and the overdue panel works normally; only the silently-failing panel is empty because the framework doesn't expose failure state on that version.

The practical value on the cron front is that Vigil catches things before the downstream complaint arrives. The dashboard surfaces the three states the moment they cross their thresholds, and the alert engine pushes an in-app or email notification to whoever you've configured to receive it. That's the difference between finding a broken cron because a customer asked about a missing invoice, and finding it because Vigil messaged you at nine in the morning.

Common questions

What version of Odoo has the auto-deactivation behaviour?
Odoo 18 and Odoo 19 both auto-deactivate crons after 5 consecutive failures over a period of at least 7 days. Odoo 17 and earlier do not - a failing cron in those versions retries indefinitely, logging a traceback each time.

Can I change the auto-deactivation thresholds?
Not through configuration. MIN_FAILURE_COUNT_BEFORE_DEACTIVATION and MIN_DELTA_BEFORE_DEACTIVATION are Python constants at the top of odoo/addons/base/models/ir_cron.py. You can monkey-patch them from a custom module if you have a legitimate reason to, but the defaults are reasonable for most deployments.

Does the admin notification always send when a cron is auto-deactivated?
The framework calls _notify_admin() with the failure message, which routes through the standard Odoo notification system. If your admin user's notification preferences send to email and email is working, yes. If your outgoing mail queue is broken (which is often why the cron is failing in the first place), no - you'll never see it.

Can I recover a cron that Odoo auto-deactivated?
Yes - go to Settings → Technical → Scheduled Actions, find the record, set Active back to True, and click Run Manually to test. Fix the underlying cause first; if the cron still fails, the counter will start over and it will be deactivated again after another 5 failures and 7 days.

Do these failure modes apply to server-actions and automated-actions too?
Server actions and automated actions run as part of triggers or user-invoked processes, not on a schedule, so the "overdue" mode doesn't apply. But if an automated action's Python code raises, the outcome depends on whether it was triggered from a user action (the whole transaction fails visibly) or from a queued job (the failure may be silent). Same underlying operational lesson: application-level failures need application-level monitoring.

Related reading


What Grafana can't tell you about your Odoo instance