How to Handle Scheduled Action Errors in Odoo 19 Tutorial
Scheduled actions in Odoo 19 — also called cron jobs — run automatically in the background to process data, sync records, send emails, and perform housekeeping tasks. When they fail silently, entire business processes can stall unnoticed for days. This complete tutorial is a beginner guide and step by step walkthrough of how Odoo 19 tracks cron failures automatically, and how to implement three practical error-handling strategies: try/except blocks for selective recovery, custom admin notification overrides, and adaptive logic that responds to failure history using the failure_count field. Apply these techniques to build cron jobs that degrade gracefully and alert your team proactively.
What You'll Learn:
- How Odoo 19 automatically tracks cron failures with failure_count and first_failure_date
- When and why Odoo deactivates a scheduled action after repeated failures
- How to use try/except blocks to catch recoverable errors without blocking the whole job
- How to override _notify_admin() to send proactive email and chat alerts
- How to read failure_count inside a cron job to switch to a safe fallback mode
- How to log errors with stack traces so you can diagnose failures from the ir_logging table
How Odoo 19 Handles Scheduled Action Failures Automatically
Before writing any custom error-handling code, it is important to understand what Odoo 19 does on its own. Every scheduled action record carries two diagnostic fields that Odoo updates automatically: failure_count and first_failure_date. When a cron job raises an unhandled exception, Odoo's internal _callback() function rolls back the database transaction, increments failure_count, records the first_failure_date if this is the first failure, marks the job status as "failed", and calls _notify_admin() to log a warning.
After five consecutive failures within seven days, Odoo automatically sets the scheduled action to inactive. This auto-deactivation prevents a broken cron from hammering an external API or filling logs indefinitely, but it also means a critical job can silently stop running without any alert reaching your team. The three methods in this tutorial give you precise control over what happens at each failure threshold.
Automatic Failure Tracking
Odoo 19 maintains failure_count and first_failure_date on every ir.cron record. Each unhandled exception increments the counter. A successful run resets it to zero. After five consecutive failures within seven days, the action is auto-deactivated to prevent cascading damage.
Try/Except in Cron Body
Wrap individual operations inside the cron method with try/except to distinguish recoverable errors (network timeouts, temporary connection failures) from unexpected exceptions. Log a warning and continue for recoverable errors; log a full stack trace and re-raise for unexpected ones so Odoo's counter still increments.
Custom Admin Notifications
Override _notify_admin() on the ir.cron model to send proactive alerts via email, Slack, Teams, or a helpdesk ticket before the action reaches the five-failure deactivation threshold. This ensures your DevOps or development team is aware of failures immediately, not after the cron has already been disabled.
Adaptive failure_count Logic
Read failure_count at the start of a cron run and switch to a read-only "safe mode" when the count exceeds a threshold. This prevents the job from performing destructive writes, API calls, or outbound emails while it is in a known-failing state, protecting data integrity and rate limits until the root cause is resolved.
Step by Step Guide: Handling Scheduled Action Errors in Odoo 19
This step by step guide covers the three methods in sequence. Method 1 protects individual operations inside the cron body. Method 2 routes failure notifications to your team. Method 3 makes the job adapt its own behaviour based on failure history. Together they give you a complete error-handling strategy for any production Odoo 19 deployment.
Understand Odoo 19's Built-In Cron Failure Mechanism
Navigate to Settings > Technical > Automation > Scheduled Actions and open any cron record. Observe the Failure Count and First Failure Date fields visible in the form. These are updated automatically by Odoo's internal _callback() function whenever a cron method raises an unhandled exception. A successful run resets failure_count to zero. When the count reaches five within a seven-day window, Odoo sets the action to inactive and calls _notify_admin(). Understanding this baseline behaviour tells you exactly what your custom code needs to add on top.
Add try/except Blocks to the Cron Method Body
Open your custom module's cron method and wrap each individual operation in a try/except block. For recoverable errors — such as a ConnectionError when an external API is temporarily down — log a warning and use continue inside a loop to skip that record and process the rest. For unexpected errors — any exception you did not anticipate — log the error with a full stack trace using _logger.error(..., exc_info=True) and then raise the exception so Odoo's _callback() still sees it, rolls back the transaction, and increments failure_count. Never silently swallow all exceptions with a bare except Exception: pass — this hides failures completely and defeats Odoo's tracking system.
Override _notify_admin() for Custom Team Alerts
Create a new model file in your custom module that inherits ir.cron. Override the _notify_admin() method to call your preferred notification channel before (or instead of) the default logging. Options include: sending an email via a mail template using self.env.ref('your_module.mail_template_cron_failure').send_mail(self.id), posting a message to a chat channel, calling a webhook for Slack or Microsoft Teams, or creating a helpdesk ticket automatically. Always call super()._notify_admin() at the end to preserve Odoo's default log entry. This override is called by Odoo automatically after each failure — no additional wiring is needed.
Read failure_count at the Start of Each Cron Run
At the top of your cron method, look up the ir.cron record for the current job and read its failure_count field. You can find the matching cron record by searching on the method name or by passing the cron ID as a parameter. Once you have the count, implement a threshold check: if failure_count >= 3 (or whatever threshold suits your use case), log a warning and return early with read-only diagnostics instead of attempting the full operation. This "safe mode" prevents the job from making additional writes, external API calls, or sending duplicate emails while it is in a known-failing state.
Implement Safe Mode and Escalation Logic
Define two operating modes for your cron job based on failure thresholds. Normal mode (failure_count below threshold): execute the full operation including writes, API calls, and email sends. Safe mode (failure_count at or above threshold): run read-only diagnostic checks, log the current state for triage, and return without modifying any records. Optionally add a second threshold for full escalation — for example, if failure_count >= 5, create a helpdesk ticket or send a high-priority alert to a manager. This tiered approach means the job keeps running and providing diagnostic information even while in a degraded state, which helps identify the root cause without causing additional damage.
Test and Monitor Your Error Handling in Staging
Before deploying to production, test each error path in staging. Temporarily raise a ConnectionError inside the cron body and confirm the warning is logged and processing continues for subsequent records. Then raise a generic Exception and confirm the full stack trace appears in the log, the transaction is rolled back, and failure_count increments in the UI. For the _notify_admin() override, manually increment failure_count in the database and trigger a cron run to confirm the email or webhook fires. After go-live, monitor the ir_logging table and the Scheduled Actions list weekly to catch any silent deactivations before they impact business operations.
Method 1: Try/Except in the Cron Method Body
The code below shows a cron method that processes a batch of sale orders, syncing each to an external system. ConnectionError is treated as recoverable — the job logs a warning and skips that order. Any other exception is unexpected — the job logs a full stack trace and re-raises so Odoo's failure counter increments correctly.
import logging
from odoo import models
_logger = logging.getLogger(__name__)
class SaleOrder(models.Model):
_inherit = 'sale.order'
def action_sync_to_external_system(self):
"""Cron method: sync confirmed orders to external ERP."""
orders = self.search([('state', '=', 'sale'), ('synced', '=', False)])
for order in orders:
try:
order._sync_to_external_system()
except ConnectionError as e:
# Recoverable: external system temporarily unavailable.
# Skip this record, log a warning, and continue the loop.
_logger.warning(
"Sync failed for order %s (ConnectionError: %s) — skipping.",
order.name, str(e)
)
continue
except Exception as e:
# Unexpected failure: log with full traceback for diagnosis,
# then re-raise so Odoo increments failure_count and rolls back.
_logger.error(
"Unexpected error syncing order %s — aborting cron run.",
order.name,
exc_info=True
)
raise
_logger.info("Sync cron completed. Processed %d orders.", len(orders))
Never Swallow All Exceptions with a Bare except: pass
A bare except Exception: pass at the top level of a cron method prevents Odoo from ever seeing a failure. The failure_count field never increments, the job stays active, and the problem becomes invisible until someone notices that data has not been updated. Always either handle the specific exceptions you expect (and re-raise the rest) or let unexpected exceptions propagate naturally so Odoo's tracking system can respond correctly.
Method 2: Override _notify_admin() for Custom Alerts
The override below sends an email to a designated DevOps address the moment any scheduled action fails. It wraps a mail template send and falls back to the default logging if the template is not found. Always call super()._notify_admin() at the end to preserve Odoo's own notification entry in the activity log.
import logging
from odoo import models
_logger = logging.getLogger(__name__)
class IrCronCustomAlert(models.Model):
_inherit = 'ir.cron'
def _notify_admin(self, message=None):
"""Send a proactive DevOps email on every cron failure."""
try:
template = self.env.ref(
'your_module.mail_template_cron_failure',
raise_if_not_found=False
)
if template:
# Send to configured recipients; force_send for immediate delivery
template.send_mail(self.id, force_send=True)
_logger.info(
"Cron failure alert sent for action: %s (failure_count=%s)",
self.name, self.failure_count
)
except Exception:
# Never let the notification itself crash the cron error handler
_logger.warning(
"Failed to send cron failure notification for %s.", self.name,
exc_info=True
)
# Always call super() to preserve Odoo's default log entry
return super()._notify_admin(message=message)
Method 3: Adaptive Logic Using failure_count
The pattern below reads failure_count at the start of each cron run and switches to a read-only diagnostic mode when the threshold is reached. This prevents repeat damage (duplicate emails, over-budget API calls, partial database writes) while the job continues to run and log useful information for triage.
import logging
from odoo import models
_logger = logging.getLogger(__name__)
SAFE_MODE_THRESHOLD = 3 # Switch to read-only after 3 consecutive failures
class SaleOrder(models.Model):
_inherit = 'sale.order'
def action_sync_to_external_system(self):
"""Cron method with failure_count-aware safe mode."""
# Look up this job's ir.cron record to read failure history
cron = self.env['ir.cron'].sudo().search(
[('model_id.model', '=', 'sale.order'),
('code', 'like', 'action_sync_to_external_system')],
limit=1
)
if cron and cron.failure_count >= SAFE_MODE_THRESHOLD:
# ── SAFE MODE: read-only diagnostics only ───────────────
_logger.warning(
"Cron '%s' is in safe mode (failure_count=%d). "
"Skipping writes. Run a manual diagnostic check.",
cron.name, cron.failure_count
)
pending_count = self.search_count(
[('state', '=', 'sale'), ('synced', '=', False)]
)
_logger.warning("Pending unsynced orders: %d", pending_count)
return # Exit without modifying any records
# ── NORMAL MODE: attempt the full operation ──────────────────
orders = self.search([('state', '=', 'sale'), ('synced', '=', False)])
for order in orders:
try:
order._sync_to_external_system()
except ConnectionError as e:
_logger.warning("Sync skipped for %s: %s", order.name, str(e))
continue
except Exception as e:
_logger.error(
"Sync failed for %s — re-raising to trigger failure tracking.",
order.name, exc_info=True
)
raise
Comparison: When to Use Each Error-Handling Method
| Method | Best For | Odoo Counter Behaviour | Complexity |
|---|---|---|---|
| Try/Except in cron body | Batch jobs where some records may fail while others succeed | Increments only if unexpected exception is re-raised | Low |
| _notify_admin() override | Production systems needing immediate DevOps alerts | No change — Odoo still increments on failure | Medium |
| failure_count adaptive logic | Jobs that cause damage when run repeatedly in a broken state | Read-only safe mode prevents new failures from accumulating | Medium |
| All three combined | Mission-critical integrations and financial workflows | Full control at every failure stage | High |
Key Insight: Odoo Auto-Deactivates After 5 Failures in 7 Days
Odoo 19 automatically deactivates any scheduled action that fails five consecutive times within a seven-day window. This is a safety net, not a notification system — your team may not notice until a critical process has been stopped for hours or days. The _notify_admin() override (Method 2) is the correct solution: it fires after every single failure, giving you advance warning before the five-failure threshold is reached and the job is disabled. Pair it with the adaptive failure_count logic (Method 3) and you have both early warning and damage prevention in a single deployment.
Frequently Asked Questions
What happens to the database when an Odoo 19 scheduled action fails?
Odoo's internal _callback() function catches the exception, rolls back the entire database transaction for that cron run, increments failure_count on the ir.cron record, updates the job status to "failed", and calls _notify_admin(). The rollback means no partial data is written — the database returns to the state it was in before the cron method started. This is why re-raising unexpected exceptions in your try/except blocks is essential: it ensures the rollback and counter increment happen correctly.
How many failures before Odoo deactivates a scheduled action?
Odoo 19 deactivates a scheduled action after five consecutive failures within a seven-day window. A successful run at any point resets the failure_count field to zero, restarting the countdown. If you want the action to stay active longer while you investigate, you can manually reset failure_count to zero in the UI or database — but this should only be done after understanding why failures are occurring, not as a way to suppress the tracking system.
Should I catch all exceptions in a scheduled action or only specific ones?
Catch only exceptions you know how to handle and from which recovery is possible — for example, ConnectionError or TimeoutError for external API calls. For all other exceptions, use a broad except Exception block solely to log the traceback and then re-raise immediately. Never swallow unknown exceptions silently, as this defeats Odoo's automatic failure tracking and makes production debugging extremely difficult.
How do I send a Slack notification when a cron job fails in Odoo 19?
Override _notify_admin() on the ir.cron model and make an HTTP POST request to your Slack incoming webhook URL inside the override. Use Python's requests library (or urllib) to post a JSON payload with the cron name, failure count, and timestamp. Wrap the webhook call in a try/except so a Slack outage never prevents Odoo's own logging from completing. Always call super()._notify_admin() at the end of your override.
Can I add custom logging to a scheduled action without a debug module?
Yes. At the top of your model file, add import logging and _logger = logging.getLogger(__name__). Then call _logger.info(), _logger.warning(), or _logger.error(..., exc_info=True) anywhere in your cron method. Odoo routes all Python log output through its own log-level filter, so your messages appear in the terminal and in the ir_logging table. They are also controllable via the --log-handler startup flag without enabling full debug mode.
Need Help Building Reliable Odoo 19 Cron Jobs?
Our certified Odoo developers can audit your scheduled actions, implement robust error handling and monitoring, and ensure your background processes never silently fail in production.
About the author
Founder & Odoo Practice Lead, Braincuber Technologies
Founder of Braincuber. Has scoped and shipped 500+ Odoo implementations for US mid-market and global brands. Takes every founder call personally — no SDR layer between buyers and the people building the system.
