この記事は現在英語版のみです。翻訳は近日公開予定です。
By the end of this recipe, your Odoo 19 instance will run a custom scheduled action — a cron job — every N minutes/hours/days, with locking to prevent concurrent execution, error handling that doesn't take down the entire scheduler, and observability via log entries and email-on-failure. Skill required: Python developer with Odoo basics. Time required: 60 minutes per cron. ECOSIRE has built dozens of crons in production for clients (and ECOSIRE.COM runs ~50 internal crons), and the recipe below is the playbook.
The mistake most teams make: writing a 200-line cron method that does everything in one transaction. When it fails, the entire transaction rolls back, the scheduler treats it as still-pending, and on next iteration it tries again — infinite loop. The recipe below uses small idempotent batches with explicit commits.
What you will need
- Odoo version: 17, 18, or 19. Cron infrastructure is identical.
- Custom module skeleton.
- Time: 60 minutes per cron.
Step-by-step
1. Define the model method
from odoo import models, api, fields
from odoo.exceptions import UserError
import logging
_logger = logging.getLogger(__name__)
class ResPartner(models.Model):
_inherit = 'res.partner'
@api.model
def _cron_recompute_segments(self):
"""Recompute customer_segment for all partners based on rolling 12-month revenue."""
BATCH_SIZE = 500
offset = 0
while True:
partners = self.search(
[('active', '=', True), ('is_company', '=', True)],
limit=BATCH_SIZE,
offset=offset,
)
if not partners:
break
for partner in partners:
try:
partner._recompute_segment()
except Exception as e:
_logger.warning('Failed to recompute segment for %s: %s', partner.id, e)
continue
self.env.cr.commit() # Per-batch commit for safety
offset += BATCH_SIZE
_logger.info('Segment recompute completed.')
Verification: calling self.env['res.partner']._cron_recompute_segments() from odoo-bin shell runs without errors.
2. Register the cron in XML
data/cron_data.xml:
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="cron_recompute_segments" model="ir.cron">
<field name="name">Recompute Customer Segments</field>
<field name="model_id" ref="base.model_res_partner"/>
<field name="state">code</field>
<field name="code">model._cron_recompute_segments()</field>
<field name="interval_number">1</field>
<field name="interval_type">days</field>
<field name="numbercall">-1</field>
<field name="active" eval="True"/>
</record>
</odoo>
Add the file to __manifest__.py data list. Verification: after install, Settings > Technical > Scheduled Actions > Recompute Customer Segments exists.
3. Add locking to prevent concurrent runs
If a cron runs longer than its interval, two instances can collide. Use a Postgres advisory lock:
@api.model
def _cron_recompute_segments(self):
LOCK_KEY = 1234567890
self.env.cr.execute("SELECT pg_try_advisory_lock(%s)", (LOCK_KEY,))
locked = self.env.cr.fetchone()[0]
if not locked:
_logger.info('Another instance is running; skipping.')
return
try:
# ... actual work ...
finally:
self.env.cr.execute("SELECT pg_advisory_unlock(%s)", (LOCK_KEY,))
Verification: launching the cron twice in parallel; the second invocation logs "skipping" and exits.
4. Track execution time and failures
Add observability:
@api.model
def _cron_recompute_segments(self):
import time
start = time.time()
failures = 0
success = 0
# ... work ...
duration = time.time() - start
self.env['cron.execution.log'].create({
'cron_name': 'recompute_segments',
'duration_sec': duration,
'records_processed': success,
'failures': failures,
})
if failures > 0:
# Email the admin
self.env.ref('your_module.email_template_cron_failure').send_mail(
self.env['cron.execution.log'].search([], limit=1).id,
force_send=True,
)
cron.execution.log is a small custom model you create:
class CronExecutionLog(models.Model):
_name = 'cron.execution.log'
_description = 'Cron execution log'
_order = 'create_date desc'
cron_name = fields.Char()
duration_sec = fields.Float()
records_processed = fields.Integer()
failures = fields.Integer()
Verification: after each cron run, a new log row appears with realistic values.
5. Configure backoff on failure
If a cron fails, Odoo retries on the next interval. To avoid hammering, add an exponential backoff:
@api.model
def _cron_recompute_segments(self):
cron = self.env.ref('your_module.cron_recompute_segments')
last_log = self.env['cron.execution.log'].search([
('cron_name', '=', 'recompute_segments'),
('failures', '>', 0),
], limit=1, order='create_date desc')
if last_log:
# Skip if last failure was less than backoff window ago
backoff_seconds = min(60 * 60, 60 * (2 ** min(last_log.failures, 10)))
if (fields.Datetime.now() - last_log.create_date).total_seconds() < backoff_seconds:
return
# ... work ...
Verification: a cron that fails 3 times in a row backs off 8 minutes, then 16, then 32 before the next attempt.
6. Add a manual trigger button
For ops teams who want to run the cron on demand:
<record id="action_recompute_segments" model="ir.actions.server">
<field name="name">Recompute Segments Now</field>
<field name="model_id" ref="base.model_res_partner"/>
<field name="state">code</field>
<field name="code">model._cron_recompute_segments()</field>
</record>
<menuitem id="menu_recompute_segments_action"
parent="base.menu_administration"
action="action_recompute_segments"/>
Verification: a Settings menu item triggers the cron on click.
7. Schedule across time zones
Crons run in the server's timezone. For "every Monday at 9 AM EST", set the cron to UTC equivalent (14:00 UTC during EDT, 13:00 UTC during EST). Or better: have the cron check the current local time and skip if outside the desired window.
Verification: the cron fires at the expected local time in your reference timezone.
8. Test the cron in development
sudo -u odoo /opt/odoo/odoo-bin shell -c /etc/odoo/odoo.conf -d test_db
>>> self.env['res.partner']._cron_recompute_segments()
>>> # Check the log
>>> self.env['cron.execution.log'].search([], limit=1).records_processed
1500
Verification: cron behaves correctly in shell and matches scheduled execution.
Common mistakes
- No batching, single transaction. A 500k-record update in one transaction holds locks and bloats WAL.
- No locking. Two concurrent runs corrupt data.
- No error handling. One bad record fails the whole batch and Odoo retries indefinitely.
- Hardcoded credentials/URLs in the cron. Use
ir.config_parameterfor environment-specific config. - Cron logic in
__init__.pyor other module-load code. Cron methods belong on models, called via their.cronframework.
Going further
Async cron with queue_job: for long-running operations, decouple the cron firing from the work via OCA's queue_job module. Cron just enqueues; workers process asynchronously. Scales horizontally — add more workers to handle more jobs.
Cron monitoring dashboard: build a small UI showing last-run time, last-run status, average duration per cron. Monitors all crons at a glance. Color-code red if a cron hasn't run in 2x its expected interval.
Slack alerting: post to a Slack channel when a cron fails 3 times in a row. Uses the Slack Incoming Webhook URL stored in ir.config_parameter. Helps the ops team catch issues before users notice.
Per-environment cron control: tag crons as dev, staging, or prod and disable irrelevant ones per environment via a config parameter. A staging environment shouldn't be sending real customer reminder emails.
Heartbeat cron: a tiny cron that runs every 5 minutes and writes "alive" to an external monitoring service (Healthchecks.io, Better Uptime). If the heartbeat stops, the monitor alerts.
Distributed cron with Redis lock: when running Odoo across multiple servers, advisory locks may not be sufficient. Use Redis SETNX with TTL as the locking primitive across nodes.
Smart skip logic: skip the cron if there's nothing to do. For example, "recompute segments" only runs if there are partner write events in the last interval. Saves DB load.
Backfill mode: when starting a new cron, run a one-off backfill of historical data before activating the schedule. Prevents the first run from doing 12 months of work and timing out.
Cron-vs-trigger trade-off: some operations are better as triggers (run immediately on event) than crons (run on schedule). Use crons for batch, time-based, or aggregation work; use automated actions / model overrides for event-driven.
Replay capability: log enough state in cron.execution.log that you can manually replay a specific cron run. Useful when debugging "what did this run actually do".
Automatic retry with idempotency: if a cron fails halfway, the next scheduled run should pick up where the previous left off, not reprocess from scratch. Idempotency + checkpointing.
For complex cron orchestration including async queues and SLA monitoring, ECOSIRE Odoo support builds the full ops layer. Pair this with how to write an Odoo migration script.
Frequently Asked Questions
How often can a cron run?
Minimum interval is 1 minute. Below that, use a long-running daemon process or a queue worker. For sub-second processing needs (real-time webhooks, etc.), use a different mechanism entirely.
What happens if Odoo restarts mid-cron?
The transaction rolls back. The cron is marked still-pending and runs again at next scheduling cycle. Always design crons to be idempotent so re-runs don't cause damage. Per-batch commits are critical for long-running crons.
Can a cron call external APIs?
Yes — use requests library with a strict timeout. Wrap in try/except so a slow API doesn't block subsequent crons. For frequently-called external APIs, use connection pooling via requests.Session().
How do I disable a cron temporarily?
Set active = False on the ir.cron record. Or update via Settings > Technical > Scheduled Actions. For environment-specific disabling (e.g., disable in staging), use ir.config_parameter flags.
Why does my cron run at unexpected times?
Odoo schedules crons based on nextcall field, which is updated relative to when the previous run completed. If a run takes longer than the interval, the next run is delayed. Check nextcall in the Scheduled Actions form.
How do I write a cron that runs on a specific day of week?
Add a Python check inside the cron body: if fields.Datetime.now().weekday() != 0: return. Schedule the cron at the desired daily time; it skips itself on non-Mondays.
Can multiple crons run in parallel?
Yes if Odoo is configured with multiple workers (workers = 4 in odoo.conf). Each cron grabs a worker. Without workers (single-process mode), crons run sequentially.
How do I monitor cron execution from outside Odoo?
Pipe ir.cron execution events to your monitoring system (Datadog, New Relic, Prometheus). Or write to a /var/log/odoo/cron.log file that ELK or similar tooling consumes.
What's the difference between ir.cron and automated.action?
ir.cron: scheduled, runs on a timer. automated.action: event-triggered, runs on record create/write/delete. Use the right one for the use case.
How do I handle daylight saving transitions?
Crons in Odoo use UTC internally. Convert to local time inside the cron body if needed. DST transitions don't affect UTC, so cron schedules survive cleanly.
Can a cron trigger another cron?
Yes — but rare. Usually cleaner to have both crons run independently on schedule. If you really need one to trigger another, write to a queue (queue.job) instead of directly invoking.
For full cron management including monitoring and alerting, ECOSIRE Odoo support builds the entire ops stack. Or read how to debug Odoo RPC calls when crons exhibit subtle issues.
執筆者
ECOSIRE TeamTechnical Writing
The ECOSIRE technical writing team covers Odoo ERP, Shopify eCommerce, AI agents, Power BI analytics, GoHighLevel automation, and enterprise software best practices. Our guides help businesses make informed technology decisions.
関連記事
Odoo フォーム ビューにカスタム ボタンを追加する方法 (2026)
Odoo 19 フォーム ビューにカスタム アクション ボタンを追加します: Python アクション メソッド、ビューの継承、条件付き可視性、確認ダイアログ。製造テスト済み。
Studio を使用せずに Odoo にカスタムフィールドを追加する方法 (2026)
Odoo 19 のカスタム モジュールを介してカスタム フィールドを追加します: モデル継承、ビュー拡張、計算フィールド、ストア/非ストアの決定。コードファースト、バージョン管理。
外部レイアウトを使用して Odoo にカスタム レポートを追加する方法
web.external_layout: QWeb テンプレート、paperformat、アクション バインディングを使用して、Odoo 19 でブランド化された PDF レポートを構築します。印刷ロゴ + フッターのオーバーライド付き。