← Back to blog

Magento 2 Cron Jobs: Setup, Custom Jobs and Troubleshooting

Rodrigo Biazzi

A large share of what keeps a Magento 2 store working happens in cron: indexers in schedule mode, order emails sent asynchronously, sitemap generation, currency rates, catalog price rules, cache cleanup and queue consumers. When cron stops, the store keeps serving pages, so nobody notices for hours or days. Then prices are wrong, emails are missing and changelog tables are huge. This guide explains how cron works in Magento Open Source and Adobe Commerce 2.4.8, how to add your own jobs, and how to find out why a job is not running.

How Magento cron works

There is exactly one entry in the system crontab, and it runs every minute:

* * * * * /usr/bin/php /var/www/html/bin/magento cron:run 2>&1 | grep -v "Ran jobs by schedule" >> /var/www/html/var/log/magento.cron.log

You can install it for the current user with bin/magento cron:install and remove it with bin/magento cron:remove. Use the PHP binary and the system user that run the web application, so file permissions on var/ and generated/ stay consistent.

Each cron:run does two things for every cron group:

  1. It generates schedule rows in the cron_schedule table for the coming minutes, based on each job's cron expression.
  2. It executes the rows that are due, then marks them as success, error or missed.

So the system crontab only starts Magento's own scheduler. The real schedule lives in XML and in the database.

Writing a custom cron job

A job is a class with a method, declared in etc/crontab.xml:

<?xml version="1.0"?>
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
        xsi:noNamespaceSchemaLocation="urn:magento:module:Magento_Cron:etc/crontab.xsd">
    <group id="default">
        <job name="acme_feed_export" instance="Acme\Feed\Cron\ExportFeed" method="execute">
            <schedule>15 */2 * * *</schedule>
        </job>
    </group>
</config>
<?php
declare(strict_types=1);

namespace Acme\Feed\Cron;

use Acme\Feed\Model\FeedExporter;
use Psr\Log\LoggerInterface;

class ExportFeed
{
    public function __construct(
        private readonly FeedExporter $exporter,
        private readonly LoggerInterface $logger
    ) {
    }

    public function execute(): void
    {
        try {
            $this->exporter->exportAll();
        } catch (\Throwable $e) {
            $this->logger->error('Feed export failed: ' . $e->getMessage(), ['exception' => $e]);
            throw $e;
        }
    }
}

Rethrowing the exception is deliberate. Magento then marks the schedule row as error and stores the message, which is where you will look first when something breaks. Swallowing exceptions makes failed runs look like successes.

Keep the cron class thin. It should call a service that also works from a CLI command, so you can run the same logic manually when debugging. The custom CLI command guide shows how to wrap the same service in a command.

Configurable schedules

Instead of a fixed expression, a job can read its schedule from system configuration with config_path:

<job name="acme_feed_export" instance="Acme\Feed\Cron\ExportFeed" method="execute">
    <config_path>acme_feed/general/cron_expr</config_path>
</job>

Add a field in system.xml for that path and admins can change the schedule without a deploy. Validate the value, because an invalid expression means the job never gets scheduled.

Cron groups

Jobs belong to groups. Core uses default, index and a few others added by modules. Each group has its own settings in etc/cron_groups.xml:

<?xml version="1.0"?>
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
        xsi:noNamespaceSchemaLocation="urn:magento:module:Magento_Cron:etc/cron_groups.xsd">
    <group id="acme_feed">
        <schedule_generate_every>1</schedule_generate_every>
        <schedule_ahead_for>4</schedule_ahead_for>
        <schedule_lifetime>2</schedule_lifetime>
        <history_cleanup_every>10</history_cleanup_every>
        <history_success_lifetime>60</history_success_lifetime>
        <history_failure_lifetime>4320</history_failure_lifetime>
        <use_separate_process>1</use_separate_process>
    </group>
</config>

The values are in minutes. schedule_lifetime is the important one for troubleshooting: if a pending job does not start within that many minutes of its scheduled time, it is marked missed. use_separate_process runs the group in its own PHP process, so a slow group does not delay the others.

A separate group is worth it for long jobs. A feed export that takes 20 minutes in the default group delays everything else in that group, including jobs that send emails. Giving it its own group isolates it. Group settings can also be changed in the admin under Stores > Configuration > Advanced > System > Cron (Scheduled Tasks).

You can run a single group manually, which is useful while testing:

bin/magento cron:run --group=acme_feed

Cron and message queue consumers

Many async features in 2.4 use message queue consumers: bulk REST operations, mass attribute updates from the product grid, data export and several inventory tasks. By default, cron starts the consumers through the consumers_runner job. You control it in app/etc/env.php:

'cron_consumers_runner' => [
    'cron_run' => true,
    'max_messages' => 10000,
    'consumers' => [],
],

An empty consumers array means all consumers. On servers where consumers are managed by Supervisor or systemd, set cron_run to false so cron does not start duplicates. For heavy queue work, a long running consumer is better than a cron job. The RabbitMQ consumers article goes into that setup.

Troubleshooting

Most cron problems can be diagnosed from the cron_schedule table:

SELECT job_code, status, COUNT(*) AS total, MAX(scheduled_at) AS last_scheduled
FROM cron_schedule
GROUP BY job_code, status
ORDER BY job_code, status;

SELECT job_code, scheduled_at, executed_at, finished_at, messages
FROM cron_schedule
WHERE status = 'error'
ORDER BY scheduled_at DESC
LIMIT 20;

No rows at all

The system crontab is not running or runs as the wrong user. Check crontab -l for the web user and look at var/log/magento.cron.log. Also check that the PHP CLI binary in the crontab is the right version, since servers often have several.

Rows stay pending

Schedules are created but nothing executes them. Common causes are a long running job holding the group lock, a PHP fatal error in a job that kills the process, or schedule_lifetime too short for how often cron actually runs. Look for jobs stuck in running with an old executed_at. If the process is gone, those rows will not recover by themselves.

Many missed rows

Cron ran too late. The server may be overloaded, the crontab may not run every minute, or another group is blocking. Moving long jobs to their own group with use_separate_process usually helps.

Your job is not in the table

The XML may be invalid, the module disabled, the config cache stale, or the config_path value empty or invalid. Clean the config cache and check that the job appears after the next cron:run.

Indexers lag behind

If indexers are in schedule mode and cron is unhealthy, the changelog tables keep growing. bin/magento indexer:status shows the backlog. More on that in the indexer modes article.

Good habits for cron jobs

  • Make jobs idempotent. A job may run twice after a restart or a manual run.
  • Process work in batches and stop after a time limit, leaving the rest for the next run.
  • Log start, end and counts at info level, and failures at error level, to a dedicated log file.
  • Do not depend on a store or design context unless you set one up explicitly. Cron runs in the crontab area, with no request and no session.
  • Monitor cron from outside. A simple check that alerts when the newest successful row in cron_schedule is older than a few minutes catches most outages early.

FAQ

How do I run a single Magento cron job manually?

Magento Open Source has no core command for a single job. Either run its group with cron:run --group, call the same service from a custom CLI command, or use a third party tool such as n98-magerun2.

Why are my cron jobs marked as missed?

They did not start within schedule_lifetime minutes of their scheduled time. Usually cron does not run every minute, the server is overloaded, or a long job in the same group is blocking.

Can I disable a core cron job?

Not with a flag. The usual approach is to override its schedule in your module's crontab.xml with an expression that never matches in practice, and document why you did it.

Should queue consumers run from cron?

It works for small stores. For heavy queues, run consumers as long running processes under Supervisor or systemd and set cron_run to false in env.php.

I am available for freelance Magento and Adobe Commerce work. If cron or background jobs are unreliable on your store, get in touch.

Related articles