Cron Job Succeeded but Did Nothing? Exit Code 0 Isn't Success
Contents
A cron job that succeeded but did nothing exited 0 because none of the commands it ran failed, and an exit code reports only that. An API that answers 200 with an empty list, a query that matches no rows, and a glob that matches no files all succeed. To catch these runs, decide what a useful run produces for each job, make the job check it before exiting 0, and report the numbers to a monitor outside the job that alerts when they're missing or out of range. Uptime checks, error trackers, and APM all see a healthy run here, because nothing was down and nothing threw.
Where Silent Success Comes From#
Each case below exits 0 and leaves nothing useful behind.
The Source Answered With Nothing#
- An empty list with a 200. An API that returns its error inside a 200 body still parses. In jq,
.data | lengthon a body withoutdatais 0, since "the length of null is zero" (jq 1.8 manual). Python's requests docs warn that a successfulr.json()"does not indicate the success of the response" (quickstart), sor.json().get("data", [])turns an outage into a quiet day. - The first page only. Paginated APIs return one page and point to the next; GitHub's REST API puts the rest behind a
linkheader (pagination). A job that ignores it never reports zero. Its signature is a count equal to the page size, run after run. - Credentials for an empty tenant. A key for the wrong account or environment authenticates and sees nothing. Stripe sandboxes and live mode have separate keys, and "objects in one mode aren't accessible to the other" (API keys), so a job deployed with an
sk_test_key lists zero live charges every night.
The Query Matched Nothing#
- A day boundary in the wrong time zone. A job scheduled for 23:30 New York time that picks its day with
date -u +%Fgets the next day's date: in UTC it's already 03:30 or 04:30, so the export holds only a few hours. - A stale replica. A replica whose replication stopped still answers queries, with nothing newer than the moment it stopped, so a job that processes rows since its last run finds none. On PostgreSQL 18,
pg_last_xact_replay_timestamp()returns the primary-side commit time of the last transaction the replica replayed (recovery functions). - A filter that stopped matching. An upstream status renamed from
completetocompletedleaves a validWHEREclause that matches no rows.
The Input Wasn't There#
In bash, a pattern that matches no files "is left unchanged" (bash(1)), so for f in /srv/incoming/*.csv runs once with the literal pattern, and a loop guarded by [ -f "$f" ] || continue skips it and exits 0. With nullglob set, the loop runs zero times, also exiting 0. A partner file that lands at 02:10 for a 02:00 job looks the same.
The Error Was Swallowed#
|| trueand catch-all handlers.except Exception: log.warning(...)around a script's main function logs the error and exits 0.- A pipeline without
pipefailreturns its last command's status.curl --fail "$url" | ./load.pyexits withload.py's status: when curl fails, the loader reads an empty stream, loads nothing, and succeeds. - The gaps in
set -e. Bash ignores-ein aniftest and in every command of an&&or||list but the last. A function called there runs with "none of the commands executed within the compound command or function body ... affected by the -e setting" (bash(1)), soif import_files; thenrunsimport_fileswith no errexit at all. Andlocal rows=$(psql ...)returns the status oflocal, not of psql (SC2155).
Cron shell script best practices covers strict mode and its gaps in full. Fixing them makes real failures exit non-zero; the first three groups still need a check on the result. Scheduled LLM and agent jobs add cases of their own, such as truncated output and refusals; monitoring scheduled AI agent runs covers them.
Decide What a Useful Run Produces#
For each job, write down what a good run leaves behind as numbers, then check the ones a broken run would get wrong:
| Check | Question | Example |
|---|---|---|
| Floor | Did it do anything? | At least one order exported |
| Relative count | Is the volume normal? | At least half of the same weekday last week |
| Freshness | Is the input current? | Newest source row under 6 hours old |
| Size | Is the output whole? | Export file of at least 2 KB |
| Ceiling | Did it do far too much? | At most 10,000 rows deleted |
ETL job monitoring applies these checks to data loads, with rejects, duplicates, reconciliation, and a separate freshness check. A job whose wrong result loses money, customer-visible data, or backups is P1 in the CronRadar Scheduled Job Methodology, which routes it to PagerDuty.
Check before you publish: write output under a temporary name or inside a transaction, check it, then rename or commit, so a bad run fails without replacing the last good output. The scheduled-automation checklist names this as one check of eight; the examples below build it out.
In Shell: An Export That Refuses to Publish Nothing#
#!/usr/bin/env bash
# /opt/exports/export-orders.sh [YYYY-MM-DD]: export one UTC day of orders as NDJSON
# and refuse to publish an export that looks empty or cut short. Needs GNU date, curl, jq.
set -euo pipefail
day=${1:-$(date -u -d yesterday +%F)}
dir=/srv/exports
state=/var/lib/export-orders/last-count
min_bytes=2048
fail() {
echo "export-orders $day: $*" >&2
exit 1
}
tmp=$(mktemp "$dir/.orders-$day.XXXXXX")
page=$(mktemp)
trap 'rm -f "$tmp" "$page"' EXIT
# The orders API returns {"data": [...], "next_cursor": "..." or null}.
cursor=
while :; do
args=(--data-urlencode "date=$day")
[ -z "$cursor" ] || args+=(--data-urlencode "cursor=$cursor")
curl --fail --silent --show-error --max-time 60 --get "${args[@]}" \
--header "Authorization: Bearer $ORDERS_API_TOKEN" \
https://api.shop.example/v1/orders > "$page"
# A 200 without a data array is an error body, not an empty day.
jq -e '.data | type == "array"' "$page" > /dev/null || fail "response has no data array"
jq -c '.data[]' "$page" >> "$tmp"
cursor=$(jq -r '.next_cursor // empty' "$page")
[ -n "$cursor" ] || break
done
count=$(wc -l < "$tmp")
bytes=$(wc -c < "$tmp")
previous=$(cat "$state" 2> /dev/null || echo 0)
[ "$count" -gt 0 ] || fail "no orders"
[ "$bytes" -ge "$min_bytes" ] || fail "only $bytes bytes"
# Under half of the previous run's count is more likely a broken source than a slow day.
[ "$previous" -eq 0 ] || [ $((count * 2)) -ge "$previous" ] ||
fail "$count orders, previous run had $previous"
chmod 0644 "$tmp"
mv "$tmp" "$dir/orders-$day.ndjson"
echo "$count" > "$state"jq -e exits non-zero when its last output is false or null (jq 1.8 manual), so an error body stops the run instead of counting as zero orders. The loop follows every page, and a failed check exits before the rename. The count is saved only after a good run, so a bad day never becomes the baseline; if volume drops for good, delete the state file.
In Python: A Rollup That Rolls Back When It Looks Wrong#
# daily_sales.py (Python 3.12+, psycopg 3.3): rebuild yesterday's sales rollup,
# check it before committing, and report the numbers (checkin.py is below).
import datetime as dt
import os
import time
import psycopg
from checkin import checkin
KEY = "daily-sales"
MAX_SOURCE_AGE_HOURS = 6 # orders arrive around the clock
class BadOutcome(Exception):
"""The run raised no error, but its output isn't what a good run produces."""
def rebuild_day(conn, day):
params = {
"day": day,
"week_ago": day - dt.timedelta(days=7),
"start": dt.datetime.combine(day, dt.time(), tzinfo=dt.UTC),
"end": dt.datetime.combine(day + dt.timedelta(days=1), dt.time(), tzinfo=dt.UTC),
}
with conn.transaction(): # an exception inside this block rolls the rebuild back
conn.execute("DELETE FROM daily_sales WHERE day = %(day)s", params)
stores = conn.execute(
"""
INSERT INTO daily_sales (day, store_id, orders, revenue_cents)
SELECT %(day)s, store_id, count(*), sum(total_cents) FROM orders
WHERE created_at >= %(start)s AND created_at < %(end)s
GROUP BY store_id
""",
params,
).rowcount
orders, week_ago = conn.execute(
"""
SELECT coalesce(sum(orders) FILTER (WHERE day = %(day)s), 0)::bigint,
coalesce(sum(orders) FILTER (WHERE day = %(week_ago)s), 0)::bigint
FROM daily_sales WHERE day IN (%(day)s, %(week_ago)s)
""",
params,
).fetchone()
age = conn.execute(
"SELECT extract(epoch FROM now() - max(created_at)) / 3600 FROM orders"
).fetchone()[0]
if orders == 0:
raise BadOutcome(f"no orders for {day}")
if age > MAX_SOURCE_AGE_HOURS:
raise BadOutcome(f"newest order is {age:.1f} hours old")
if week_ago > 0 and orders * 2 < week_ago:
raise BadOutcome(f"{orders} orders for {day}, {week_ago} a week earlier")
return {"orders": orders, "stores_reporting": stores, "source_age_hours": round(float(age), 1)}
def main():
yesterday = dt.datetime.now(dt.UTC).date() - dt.timedelta(days=1)
with psycopg.connect(os.environ["DATABASE_URL"], autocommit=True) as conn:
return rebuild_day(conn, yesterday)
if __name__ == "__main__":
run_id = f"{KEY}-{int(time.time())}-{os.getpid()}"
checkin(KEY, "start", run_id, schedule="30 1 * * *", gracePeriod=600)
try:
outcomes = main()
except Exception as exc:
# Other errors can quote row values, so send only their type.
message = str(exc) if isinstance(exc, BadOutcome) else type(exc).__name__
checkin(KEY, "fail", run_id, message=message[:1000])
raise
checkin(KEY, "complete", run_id, body={"outcomes": outcomes})psycopg rolls a conn.transaction() block back when an exception is raised inside it (transactions), so a run that fails a check leaves that day's previous rollup untouched, and the exception exits the process non-zero. The freshness check reads the source, not the output: a stale replica, a stopped upstream writer, and a broken ingest all show up as an old newest order.
Report the Numbers So the Check Lives Outside the Job#
An assertion inside the job disappears when someone edits the job, and nothing tells you. A monitor that expects a number keeps expecting it, so a refactor that stops sending the value fails the run as missing. An outside check also changes without a deploy, and can watch a job you can't edit by measuring what it produced.
CronRadar does this with outcome checks. In the automation's settings, under Outcome checks, configure up to five expectations: a number compared with >=, <=, or =, or a boolean that must be true or false. The job then POSTs complete with a JSON body under outcomes: at most five values, keys of lowercase letters, digits, and underscores starting with a letter (64 characters at most), each a finite number or boolean. Keys without an expectation are accepted but not stored. A missing or violated expectation marks the run outcome-failed and opens an incident showing observed and expected values. A bare heartbeat has no body, so it fails configured checks as missing. The HTTP API reference has the full rules.
There is no installable CronRadar SDK for shell or Python today and no auto-discovery for them, so each job reports with its own HTTP check-ins. The .NET SDK 1.0.13 can't send outcome values; HTTP can.
A Shell Wrapper That Measures the Export#
This wrapper runs the export and measures the published file itself, independent of the script's own counting:
#!/bin/sh
# /opt/exports/export-orders-monitored.sh: run the export, then report what it published.
key=orders-export
url="https://cron.life/api/v1/ping/$key"
run_id="$key-$(date +%s)-$$"
day=$(date -u -d yesterday +%F) # GNU date
out="/srv/exports/orders-$day.ndjson"
checkin() {
event=$1
shift
curl --fail --silent --show-error --max-time 5 --output /dev/null \
--request POST --get --user "$CRONRADAR_API_KEY:" \
--data-urlencode "schedule=15 1 * * *" \
--data-urlencode "gracePeriod=600" \
--data-urlencode "run_id=$run_id" \
"$@" "$url/$event" || true
}
checkin start
/opt/exports/export-orders.sh "$day"
rc=$?
if [ "$rc" -ne 0 ]; then
checkin fail --data-urlencode "message=export-orders.sh exited with code $rc"
exit "$rc"
fi
rows=0
bytes=0
published=false
if [ -f "$out" ]; then
rows=$(wc -l < "$out")
bytes=$(wc -c < "$out")
published=true
fi
body=$(printf '{"outcomes":{"rows_exported":%d,"export_bytes":%d,"file_published":%s}}' \
"$rows" "$bytes" "$published")
curl --fail --silent --show-error --max-time 5 --output /dev/null \
--request POST --user "$CRONRADAR_API_KEY:" \
--header "Content-Type: application/json" \
--data "$body" "$url/complete?run_id=$run_id" || true
exit 0Schedule it in cron for 01:15 UTC, matching the schedule it sends; CronRadar reads schedules in UTC. The start carries the schedule, so the first run creates the monitor and a later run recreates a deleted one. Schedule and grace on a check-in apply only when it creates the monitor; change them afterwards in the monitor's settings. A fail alerts immediately. Each request gives up after five seconds and ignores errors, and the wrapper keeps the job's exit code. The JSON request leaves out --get, which would move its data into the query string (curl).
Then configure the expectations, with floors from your own history:
| Outcome check | Catches |
|---|---|
rows_exported >= 800 |
A partial export: one page, or one region's orders missing |
export_bytes >= 200000 |
Rows that arrived mostly empty |
file_published is true |
A script that exited 0 but wrote somewhere else after an edit |
Test the body once by hand. CronRadar rejects an outcome body it can't accept, such as a sixth value, an uppercase key, or a string, with a 400 and doesn't record the completion; since the next run's start still arrives on schedule, that can go on without an alert. Send it with --fail-with-body instead of --fail, and without || true, to see the error.
Reporting From Python#
daily_sales.py reports through this helper:
# checkin.py: CronRadar HTTP check-ins with a 5-second timeout. Errors are printed and
# ignored, so monitoring never changes the job's result.
import base64
import json
import os
import sys
import urllib.parse
import urllib.request
def checkin(key, event, run_id, body=None, **params):
try:
query = urllib.parse.urlencode({"run_id": run_id, **params}, quote_via=urllib.parse.quote)
request = urllib.request.Request(
f"https://cron.life/api/v1/ping/{urllib.parse.quote(key, safe='')}/{event}?{query}",
data=json.dumps(body).encode() if body is not None else None,
method="POST",
)
token = base64.b64encode(f"{os.environ['CRONRADAR_API_KEY']}:".encode()).decode()
request.add_header("Authorization", f"Basic {token}")
if body is not None:
request.add_header("Content-Type", "application/json")
urllib.request.urlopen(request, timeout=5).close()
except Exception as exc:
print(f"CronRadar {event} check-in failed: {exc}", file=sys.stderr)urlopen raises on a 4xx or 5xx response, and timeout=5 bounds the connection attempt and each read (urllib.request); the except absorbs both. The job re-raises its own exception after reporting fail.
Choose Thresholds That Catch Breakage, Not Slow Days#
Take Floors From History#
A floor sits well below the lowest normal value and far above what a broken run reports. Look up the lowest and highest daily totals for each weekday over the last three months, then start with a floor at half the lowest and a ceiling at twice the highest.
Let Seasonality Pick the Comparison#
Comparing with the previous run is the simplest relative check and the noisiest: a business that's quiet on weekends trips it every Saturday. The same weekday last week absorbs that cycle; holidays and month-end still break it, so expect a few known false alarms a year or skip the check on dates you list. Freshness needs the same thought: for a source idle from Friday evening to Monday morning, the newest record is about 60 hours old at Monday's early run. If a job has nothing to do on some days, schedule it only on the others, such as 0 6 * * 1-5, so the monitor doesn't expect those runs.
CronRadar compares each value with a fixed number. For a relative check outside the job, have the job compute the ratio and report it, as in pct_of_last_week >= 50.
Allow Zero Where Zero Is Normal#
A cleanup that finds nothing expired, or a reminder job with no overdue invoices, may rightly do nothing. Check that it looked: report what it read separately from what it changed, and put the floor on the first, invoices_checked >= 1, with none on reminders_sent. An idempotent job that finds its period already claimed also does nothing on purpose, so send check-ins only from the run that holds the claim.
Decide Between Failing the Job and Alerting#
Fail in the job when publishing would do harm: an empty export that replaces a partner's file, a rollup of zeros on a dashboard. Leave the plausible-but-low range to the outcome check, which alerts without stopping the pipeline. In CronRadar, an outcome-failed run opens one incident, later failing runs don't open another while it's open, and the next passing run closes it with a recovery alert. A check that fires on normal days teaches people to ignore it, so start loose and tighten as runs accumulate.
What Not to Send#
Outcome values are evidence that a run did its work, not a copy of it: counts, sizes, ages, and flags. Never send payloads, records, logs, secrets, tokens, or personal data, and don't build identifiers into key names, as in customer_4182_orders. The same applies to the fail message, capped at 1,000 characters, which is why daily_sales.py sends only the type of errors it didn't raise itself. Backups need their own evidence, from tables dumped to a restore drill; monitoring backup jobs builds those checks.
Frequently Asked Questions#
How can a cron job succeed but do nothing?#
Every command it ran exited 0 while no work happened: an API returned an empty list with a 200, a query or glob matched nothing, the credentials pointed at an empty account, or an error was caught and logged instead of raised. The exit code only says nothing failed; a check on the result shows whether the run did its work.
How do I alert when a scheduled job processes zero records?#
Make the job exit non-zero when the count is zero and zero is never normal, so the scheduler and any failure monitor see a failed run. For a check outside the job, send the count with the run's completion and alert when it's missing or below a floor; in CronRadar, that's an outcome check such as records_processed >= 1.
What should a scheduled job check before exiting 0?#
That it processed at least what a good run processes, that its volume matches the same weekday last week within a tolerance, that its input is fresh, and that its output exists at a plausible size. Check before publishing or committing, so a failure leaves the last good output in place.
How do I monitor the output of a cron job?#
Measure the output after the run, such as rows written, file size, and the newest record's age, and send the numbers with the run's completion to a monitor that holds the expected ranges. A wrapper can measure what a job produced without changing the job.
What are outcome checks in CronRadar?#
Up to five expectations per automation, such as rows_exported >= 800 or file_published is true, compared with the numbers or booleans a job sends on its complete check-in over HTTP. A missing or violated value marks the run outcome-failed and opens an incident showing observed and expected values.
Put this guide to work
Know when the next run is missing
Connect one scheduled job, confirm a real completion, then verify the alert destination. Keep your existing scheduler in charge of running the work.