# Cron Job Succeeded but Did Nothing? Exit Code 0 Isn't Success

By Dan Ochoa · Published 2026-10-25 · Updated 2026-10-25
Canonical: https://cronradar.com/blog/scheduled-job-silent-success

> Why a cron job can exit 0 without doing its work, how to make it check its own output in shell and Python, and how to alert when the numbers look wrong.

**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](https://cronradar.com/blog/cron-monitoring-vs-uptime-monitoring), 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 | length` on a body without `data` is 0, since "the length of null is zero" ([jq 1.8 manual](https://jqlang.org/manual/)). Python's requests docs warn that a successful `r.json()` "does not indicate the success of the response" ([quickstart](https://requests.readthedocs.io/en/latest/user/quickstart/)), so `r.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 `link` header ([pagination](https://docs.github.com/en/rest/using-the-rest-api/using-pagination-in-the-rest-api)). 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](https://docs.stripe.com/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 +%F` gets 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](https://www.postgresql.org/docs/current/functions-admin.html#FUNCTIONS-RECOVERY-INFO-TABLE)).
- **A filter that stopped matching.** An upstream status renamed from `complete` to `completed` leaves a valid `WHERE` clause that matches no rows.

### The Input Wasn't There

In bash, a pattern that matches no files "is left unchanged" ([bash(1)](https://man7.org/linux/man-pages/man1/bash.1.html)), 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

- **`|| true` and catch-all handlers.** `except Exception: log.warning(...)` around a script's main function logs the error and exits 0.
- **A pipeline without `pipefail`** returns its last command's status. `curl --fail "$url" | ./load.py` exits with `load.py`'s status: when curl fails, the loader reads an empty stream, loads nothing, and succeeds.
- **The gaps in `set -e`.** Bash ignores `-e` in an `if` test 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)](https://man7.org/linux/man-pages/man1/bash.1.html)), so `if import_files; then` runs `import_files` with no errexit at all. And `local rows=$(psql ...)` returns the status of `local`, not of psql ([SC2155](https://www.shellcheck.net/wiki/SC2155)).

[Cron shell script best practices](https://cronradar.com/blog/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](https://cronradar.com/blog/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](https://cronradar.com/blog/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](https://cronradar.com/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](https://cronradar.com/blog/prevent-errors-in-scheduled-automation) names this as one check of eight; the examples below build it out.

### In Shell: An Export That Refuses to Publish Nothing

```bash
#!/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](https://jqlang.org/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

```python
# 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](https://www.psycopg.org/psycopg3/docs/basic/transactions.html)), 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](https://docs.cronradar.com/api-reference) has the full rules.

_Figure: Exiting 0 isn't the signal: the complete check-in carries rows_exported under outcomes, and the monitor compares it with the configured expectation. A missing value, as from a bare heartbeat with no body, or a violated one marks the run outcome-failed and opens an incident showing observed and expected values._

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:

```bash
#!/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 0
```

Schedule 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](https://curl.se/docs/manpage.html)).

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:

```python
# 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](https://docs.python.org/3/library/urllib.request.html#urllib.request.urlopen)); 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](https://cronradar.com/blog/idempotent-scheduled-jobs) 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](https://cronradar.com/blog/monitor-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.
