How to check cron logs on Linux

Introduction

Cron logs that it ran your job. It does not log what your job said.

That gap explains most of the frustration with cron logs. The record exists. It just answers a narrower question than the one you came with. This guide covers where cron writes its log on each distribution, how to read it, the five reasons it comes back empty, and what to do once you notice the log cannot tell you whether the job actually worked.

The command that works on any systemd machine

journalctl -u cron --since today

On Red Hat, Fedora, Alma, Rocky and SUSE the unit is crond:

journalctl -u crond --since today

Wrong name and you get -- No entries --, which reads exactly like “cron never ran”. Check first:

systemctl list-units --type=service | grep -i cron

If neither name is there, cron may not be installed at all. Container base images and some minimal cloud images ship without it.

You can also match on the process rather than the unit. This catches both names at once:

journalctl _COMM=cron _COMM=crond --since today

Multiple values for the same journal field are OR’d, so that one command works everywhere.

Where the file lives

No systemd, or you want the file on disk? Cron logs through syslog, using the cron facility. Where that lands depends on what is reading syslog.

Debian and Ubuntu. Cron goes into /var/log/syslog with everything else:

grep CRON /var/log/syslog

Both ship the dedicated cron.* rule commented out. Uncomment it in /etc/rsyslog.d/50-default.conf and restart rsyslog to get a separate /var/log/cron.log.

Red Hat, Fedora, Alma, Rocky. Cron gets its own file:

grep CROND /var/log/cron

Anywhere. Stop guessing and ask rsyslog what it does with the cron facility:

grep -r "cron\." /etc/rsyslog.conf /etc/rsyslog.d/

The path on the right of the match is your answer.

Read the line

A cron entry has five parts and no more:

Aug 22 03:00:01 web1 CRON[2453]: (root) CMD (/usr/local/bin/backup.sh)

Time. Host. Cron’s PID for that run. The user it ran as. The command, exactly as written in the crontab.

Debian and Ubuntu wrap each run in a pair of PAM lines:

Aug 22 03:00:01 web1 CRON[2453]: pam_unix(cron:session): session opened for user root(uid=0) by (uid=0)
Aug 22 03:00:02 web1 CRON[2453]: pam_unix(cron:session): session closed for user root

Noise, mostly. Filter it out by keeping only the CMD lines:

journalctl -u cron --since today | grep "CMD ("

Cronie on the RHEL family can log a job’s own output as CMDOUT lines. Check before you assume the output is gone:

journalctl -u crond --since today | grep CMDOUT

Debian’s cron does not produce those. See where cron job output goes for what happens to it instead.

Five reasons the log is empty

The unit is named the other thing. cron on Debian and Ubuntu, crond on the RHEL family. Covered above because it is the most common one by a distance.

There is no rsyslog. Recent minimal and cloud images often skip it, and without it /var/log/syslog does not exist. Check with systemctl status rsyslog. The journal still has the entries.

The journal is not persistent. Without a /var/log/journal directory, journald keeps logs in /run and loses them at reboot. So the job that ran before the reboot has no record now:

journalctl --list-boots

One boot listed on a machine that has been up for weeks means volatile storage. sudo mkdir -p /var/log/journal && sudo systemctl restart systemd-journald fixes it going forward — and read the disk usage guide before you do, because a persistent journal grows.

Cron’s logging is turned down. Debian takes a bitmask in /etc/default/cron:

grep EXTRA_OPTS /etc/default/cron

-L 0 disables cron’s logging entirely. Somebody usually added it to quiet the PAM lines.

It is not a cron job. Distributions have been moving packaged periodic work to systemd timers, which log under their own service names and never touch the cron facility:

systemctl list-timers --all

What the log does not tell you

Look at the line again:

Aug 22 03:00:01 web1 CRON[2453]: (root) CMD (/usr/local/bin/backup.sh)

Cron writes that when it starts the job. It is not a result. Absent from the line, and absent from every other line cron writes:

  • The exit status. A job that exits 1 logs the same as a job that exits 0.
  • The output. Stdout and stderr go to mail, not to the log — apart from the CMDOUT case above.
  • The duration. There is no matching finish line to subtract.
  • Whether the job did anything. A backup script that could not reach the database still logged one clean CMD line.

So grep CRON proves cron tried. It never proves the work happened. Everything below is about closing that gap.

Reading the cron log on every host at once

The single-machine version of this ends at grep. The version that scales does not, because the question is rarely “did it run here” — it is “which of the forty hosts skipped it”.

Central Logging collects the journal from each host with the CL Agent and stores it in SQLite, so cron lines from every machine sit in one table you can query with SQL.

Install the agent, point it at your server, and the journal ships as it arrives — including everything cron writes. Setup is in the CL Agent docs.

The agent sends journalctl -o json, so each row is a journal entry object. Cron entries across the fleet:

SELECT datetime(CAST(json_extract(msg,'$.__REALTIME_TIMESTAMP') AS INTEGER)/1000000,'unixepoch') AS at,
       json_extract(msg,'$._HOSTNAME') AS host,
       json_extract(msg,'$.MESSAGE') AS line
FROM logs
WHERE json_valid(msg)
  AND json_extract(msg,'$.SYSLOG_IDENTIFIER') IN ('CRON','CROND')
ORDER BY at DESC;

IN ('CRON','CROND') covers both distribution families in one query, which is the point of putting them in the same table.

Keep the WHERE json_valid(msg) guard. One plain-text line anywhere in the source — a syslog relay, an appliance, a stray echo — makes json_extract raise “malformed JSON” and the whole query fails. The guard sits in the WHERE clause on purpose. SQLite short-circuits AND there, so the extracts after it never run on a bad row.

Which hosts ran a named job today:

SELECT DISTINCT json_extract(msg,'$._HOSTNAME') AS host
FROM logs
WHERE json_valid(msg)
  AND json_extract(msg,'$.SYSLOG_IDENTIFIER') IN ('CRON','CROND')
  AND json_extract(msg,'$.MESSAGE') LIKE '%backup.sh%'
  AND "timestamp" > CAST(strftime('%s','now','-1 day') AS INTEGER);

Compare that against your host list and the missing names are the answer.

Runs per host, to spot the machine whose crontab drifted:

SELECT json_extract(msg,'$._HOSTNAME') AS host, COUNT(*) AS runs
FROM logs
WHERE json_valid(msg)
  AND json_extract(msg,'$.SYSLOG_IDENTIFIER') IN ('CRON','CROND')
  AND json_extract(msg,'$.MESSAGE') LIKE '%CMD (%'
  AND "timestamp" > CAST(strftime('%s','now','-1 day') AS INTEGER)
GROUP BY host
ORDER BY runs DESC;

LIKE '%CMD (%' keeps the run lines and drops the PAM pairs. It also drops CMDOUT lines, because there the CMD is followed by OUT rather than a space. Search for '%CMDOUT%' when you want the output instead.

One caveat on time. The timestamp column is when the server received the line, not when the event happened. After a network outage a backlog arrives at once and sorts as if it just happened. For true event order use __REALTIME_TIMESTAMP, as the first query does. For “recently arrived”, timestamp is the right column and it is faster.

Being told instead of looking

A query you have to remember to run is a query you will not run. Turn the important ones into alert rules.

Rules are evaluated every five minutes, and the only thing that matters is whether the result set is empty. So SELECT 1 ... LIMIT 1 is the right shape for every rule. There is nothing to gain by selecting columns.

The nightly job did not run anywhere. Set the condition to Alert on empty:

SELECT 1 FROM logs
WHERE json_valid(msg)
  AND json_extract(msg,'$.SYSLOG_IDENTIFIER') IN ('CRON','CROND')
  AND json_extract(msg,'$.MESSAGE') LIKE '%CMD (/usr/local/bin/backup.sh)%'
  AND "timestamp" > CAST(strftime('%s','now','-25 hours') AS INTEGER)
LIMIT 1;

Twenty-five hours, not twenty-four, so a job that drifts by a few minutes does not page you.

A job printed something. On the RHEL family, where CMDOUT exists, output usually means trouble. Alert on results:

SELECT 1 FROM logs
WHERE json_valid(msg)
  AND json_extract(msg,'$.SYSLOG_IDENTIFIER') IN ('CRON','CROND')
  AND json_extract(msg,'$.MESSAGE') LIKE '%CMDOUT%'
  AND "timestamp" > CAST(strftime('%s','now','-10 minutes') AS INTEGER)
LIMIT 1;

Alerts go to one Slack, Telegram or Pushover channel — the one marked default on the Alerts page. Delivery lands within a minute of the rule firing. No all-clear message is sent when the condition clears, so treat an alert as “go look”, not as an incident that closes itself.

The better signal is the job reporting for itself

Both rules above infer health from cron’s own log. That works, and it inherits every limitation of the log: no exit status, no output, no duration.

A job that reports its own result removes the guessing. Central Logging’s cron monitoring takes a ping from the job itself:

0 3 * * * /usr/local/bin/backup.sh && curl -fsS "https://logs.example.com/p/YOUR-API-KEY/nightly-backup?state=complete"

The job only pings on success, so a non-zero exit is caught by the missing check-in. To alert the moment it fails instead, wrap the job in a script that pings state=fail from a trap — the wrapper is in monitoring cron jobs, and a fail ping notifies within a minute.

There is no schedule to declare. The expected interval is the median gap between the last hundred completed runs, and a job is flagged late once it is 20% past that. Two consequences worth knowing. It needs at least two completions before it can call anything late. And a deliberately irregular job gets a wobbly baseline. A declared schedule is more precise. A learned one cannot fall out of sync with your crontab.

Where to go next

💌 Get notified on new features and updates

Only sent when a new version is released. Nothing else.