# Monitoring pg_cron Jobs in PostgreSQL and Supabase

By Dan Ochoa · Published 2026-10-25 · Updated 2026-10-31
Canonical: https://cronradar.com/blog/pg-cron-monitoring

> How pg_cron runs scheduled SQL, how to read cron.job_run_details, why jobs stop running, and how to get alerted when a pg_cron job fails or misses a run.

**To monitor pg_cron, query `cron.job_run_details` for each job's runs, and have something outside the database turn those runs into alerts, because pg_cron records failures but never notifies anyone.** Every run gets a row with its status, return message, and timing, but nothing reads that table for you, and a run that never starts leaves no row at all. A small process on another host that reports each finished run to a heartbeat monitor covers both: failures arrive as failures, and runs that never happen arrive as silence. This guide covers pg_cron 1.6.8 (September 2026) on self-hosted PostgreSQL, Amazon RDS and Aurora, Google Cloud SQL, Azure, and Supabase, with provider details checked October 25, 2026. pg_cron runs SQL only; `pg_dump` backups run from a host's scheduler, and [monitoring backup jobs](https://cronradar.com/blog/monitor-backup-jobs) covers those.

## How pg_cron Runs a Job

pg_cron's scheduler is a background worker inside the server. It reads jobs from `cron.job` in the database named by `cron.database_name` (`postgres` by default), and the extension refuses to install in any other database ([pg_cron.sql](https://github.com/citusdata/pg_cron/blob/v1.6.8/pg_cron.sql#L1-L13)). Each run opens a libpq connection to the job's database as the job's role, or starts a background worker when `cron.use_background_workers = on` ([README](https://github.com/citusdata/pg_cron/blob/v1.6.8/README.md)).

```sql
-- Runs at 03:30 in cron.timezone, which is GMT unless you change it.
SELECT cron.schedule(
  'nightly-cleanup',
  '30 3 * * *',
  $$DELETE FROM sessions WHERE expires_at < now() - interval '30 days'$$
);
```

Scheduling the same name again replaces your role's job of that name ([job_metadata.c](https://github.com/citusdata/pg_cron/blob/v1.6.8/src/job_metadata.c#L246-L253)), so the call is safe in a migration. Since 1.4, `cron.schedule_in_database` targets another database on the server, and `cron.alter_job(job_id, active := false)` pauses a job ([changelog](https://github.com/citusdata/pg_cron/blob/v1.6.8/CHANGELOG.md)). `cron.unschedule('nightly-cleanup')` deletes it and keeps its history ([Supabase Cron](https://supabase.com/docs/guides/cron/quickstart)).

| Where                                                                                                                     | Turn it on                                                                  | What's different                                                                                        |
| ------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------- |
| Self-hosted                                                                                                               | `shared_preload_libraries = 'pg_cron'`, restart, `CREATE EXTENSION pg_cron` | Runs connect over libpq unless background workers are on                                                |
| [RDS 12.5+, Aurora 12.6+](https://docs.aws.amazon.com/AmazonRDS/latest/UserGuide/PostgreSQL_pg_cron.html)                 | Add it to `shared_preload_libraries` in the parameter group, reboot         | Background workers, not changeable; AWS lists 5 concurrent jobs as the default                          |
| [Cloud SQL](https://cloud.google.com/sql/docs/postgres/extensions)                                                        | The `cloudsql.enable_pg_cron` flag                                          | Background workers only; pg_cron 1.6.4                                                                  |
| [Azure flexible server](https://learn.microsoft.com/en-us/azure/postgresql/extensions/concepts-extensions-considerations) | Allowlist it, add it to `shared_preload_libraries`, restart                 | `cron.schedule_in_database` with a `username` isn't supported                                           |
| [Supabase](https://supabase.com/docs/guides/cron)                                                                         | The Cron integration in the dashboard, or `create extension pg_cron`        | Recommends at most 8 concurrent jobs, each under 10 minutes; seconds schedules need Postgres 15.1.1.61+ |

### Schedule Syntax and Time Zone

pg_cron parses schedules with Vixie cron's code: five fields, month and weekday names, `0` or `7` for Sunday, and `@hourly`, `@daily`, `@weekly`, `@monthly`, and `@reboot` ([entry.c](https://github.com/citusdata/pg_cron/blob/v1.6.8/src/entry.c#L131-L283)). It adds `$` for the last day of the month (1.6.0+) and intervals from `'1 seconds'` to `'59 seconds'` (1.5.0+). When both day fields are restricted, a date matching either runs the job. pg_cron 1.6.8 adds a setting to require both, `cron.match_dom_and_dow` in the [source](https://github.com/citusdata/pg_cron/blob/v1.6.8/src/pg_cron.c#L355-L363), though the changelog calls it `cron.dom_dow_and_logic`; `SELECT name FROM pg_settings WHERE name LIKE 'cron.%'` shows what your server has.

Schedules are read in GMT unless `cron.timezone` (1.5.0+) names another zone, which takes a restart ([pg_cron.c](https://github.com/citusdata/pg_cron/blob/v1.6.8/src/pg_cron.c#L345-L353)). AWS, Azure, and Supabase all write their examples in GMT. The scheduler steps through real minutes and converts each to that zone ([ShouldRunTask](https://github.com/citusdata/pg_cron/blob/v1.6.8/src/pg_cron.c#L997-L1032)), so under a daylight saving zone, a job in the skipped hour doesn't run that day and one in the repeated hour runs twice. Leave it on GMT.

## Reading cron.job_run_details

With `cron.log_run` on, the default, each run gets a row with `jobid`, `runid`, `job_pid`, `database`, `username`, `command`, `status`, `return_message`, `start_time`, and `end_time` ([schema](https://github.com/citusdata/pg_cron/blob/v1.6.8/pg_cron--1.2--1.3.sql#L4-L20)). `status` goes from `starting` (plus `connecting` and `sending` in connection mode) to `running`, then `succeeded` or `failed` ([job_metadata.c](https://github.com/citusdata/pg_cron/blob/v1.6.8/src/job_metadata.c#L1460-L1489)). A success's `return_message` is the command tag, such as `DELETE 4182`; a failure's is the error.

```sql
-- The latest runs of every job, with durations.
SELECT j.jobname, d.runid, d.status, d.return_message,
       d.start_time, d.end_time - d.start_time AS duration
FROM cron.job_run_details d
JOIN cron.job j ON j.jobid = d.jobid
ORDER BY d.runid DESC
LIMIT 20;

-- Recent failures, including runs cut off by a restart.
SELECT j.jobname, d.runid, d.start_time, d.return_message
FROM cron.job_run_details d
JOIN cron.job j ON j.jobid = d.jobid
WHERE d.status = 'failed'
ORDER BY d.runid DESC
LIMIT 20;
```

Sort by `runid`: `start_time` is empty until a run reaches `running`, and a run interrupted by a restart never gets an `end_time`. pg_cron's own messages point to the cause ([pg_cron.c](https://github.com/citusdata/pg_cron/blob/v1.6.8/src/pg_cron.c)): `server restarted` (the run was in progress when the server stopped, [job_metadata.c](https://github.com/citusdata/pg_cron/blob/v1.6.8/src/job_metadata.c#L1419-L1446)), `connection failed` (couldn't log in as the job's role; details in the server log), `job startup timeout` (no start within 10 seconds), `could not start background process` (no free worker slot), and `job canceled` (unscheduled or deactivated mid-run).

Row-level security shows each role only its own jobs and runs; superusers and `BYPASSRLS` roles see all ([README](https://github.com/citusdata/pg_cron/blob/v1.6.8/README.md)), as does `rds_superuser` on RDS.

pg_cron never deletes history, and the table's only index is its `runid` primary key. Purge it on a schedule, as a role that sees every job, since each role can delete only its own rows:

```sql
SELECT cron.schedule(
  'purge-job-run-details',
  '0 12 * * *',
  $$DELETE FROM cron.job_run_details
    WHERE coalesce(end_time, start_time) < now() - interval '7 days'$$
);
```

The README's version filters on `end_time` alone, which never matches runs cut off by a restart.

## Overlaps, Downtime, and Standbys

- **One run at a time per job.** A run due while the previous one is still going is counted and started as soon as it ends, and the count keeps growing: an every-minute job that runs ten minutes is followed by several runs back to back ([pg_cron.c](https://github.com/citusdata/pg_cron/blob/v1.6.8/src/pg_cron.c#L1850-L1858)). Seconds-interval jobs queue at most one ([pg_cron.c](https://github.com/citusdata/pg_cron/blob/v1.6.8/src/pg_cron.c#L743-L757)). Make jobs safe to run twice; [idempotent scheduled jobs](https://cronradar.com/blog/idempotent-scheduled-jobs) shows how.
- **No catch-up.** The scheduler starts counting from the current minute when it starts ([pg_cron.c](https://github.com/citusdata/pg_cron/blob/v1.6.8/src/pg_cron.c#L760-L763)), so runs due while the server was down or failing over never happen. [Missed runs after downtime](https://cronradar.com/blog/missed-runs-after-downtime) compares other schedulers.
- **Standbys run nothing.** The scheduler starts only in normal read-write state ([pg_cron.c](https://github.com/citusdata/pg_cron/blob/v1.6.8/src/pg_cron.c#L367), [background workers](https://www.postgresql.org/docs/18/bgworker.html)), so a read replica holds the jobs but runs them only once promoted.
- **A concurrency cap.** `cron.max_running_jobs` defaults to 32 in connection mode and 5 with background workers, lowered when `max_connections`, or `max_worker_processes` minus one, is smaller ([pg_cron.c](https://github.com/citusdata/pg_cron/blob/v1.6.8/src/pg_cron.c#L309-L332)). A run held back starts late and has no row until then.

A hung run holds its job's later runs behind it. Schedule long jobs as a role used only by them and give it a limit, such as `ALTER ROLE nightly_jobs SET statement_timeout = '20min'`: connection-mode runs are new sessions that pick up role settings ([ALTER ROLE](https://www.postgresql.org/docs/18/sql-alterrole.html)), and background-worker runs apply the timeout to the whole command ([pg_cron.c](https://github.com/citusdata/pg_cron/blob/v1.6.8/src/pg_cron.c#L2151-L2165)). A hang then ends as a failed run.

## Why a pg_cron Job Is Not Running

pg_cron's settings are readable only by superusers and roles with [`pg_read_all_settings`](https://www.postgresql.org/docs/18/predefined-roles.html).

```sql
SHOW shared_preload_libraries;   -- must include pg_cron
SHOW cron.database_name;         -- the database that holds cron.job
SELECT pg_is_in_recovery();      -- true means a standby, which runs no jobs
SELECT pid FROM pg_stat_activity WHERE application_name = 'pg_cron scheduler';
SELECT jobid, jobname, schedule, active, database, username, nodename FROM cron.job;
```

The scheduler names its session `pg_cron scheduler` and logs `pg_cron scheduler started` ([pg_cron.c](https://github.com/citusdata/pg_cron/blob/v1.6.8/src/pg_cron.c#L600-L657)), so for a role that can see other sessions, no row from the fourth query means no scheduler.

| Symptom                                                   | Cause                                                                                                                    | Fix                                                                                                                                                                             |
| --------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| "pg_cron can only be loaded via shared_preload_libraries" | Library not preloaded, or no restart since                                                                               | Add it and restart                                                                                                                                                              |
| "can only create extension in database postgres"          | You're not in `cron.database_name`                                                                                       | Create it there; use `cron.schedule_in_database` for other databases                                                                                                            |
| No new rows for any job                                   | Library removed, server is a standby, `cron.database_name` changed, `cron.launch_active_jobs` off, or `cron.log_run` off | Run the checks above; with `cron.log_run` off, read the server log                                                                                                              |
| One job has no new rows                                   | `active` is false, the schedule is read in GMT, or the previous run is still going                                       | Check `cron.job` and the job's latest row                                                                                                                                       |
| `connection failed` on every run                          | Connection mode: `pg_hba.conf` won't let the job's role reach `nodename`:`nodeport` without a password                   | A [`.pgpass`](https://www.postgresql.org/docs/18/libpq-pgpass.html) entry for the server's account, `cron.host` set to the socket directory with `trust`, or background workers |
| `could not start background process`                      | No free `max_worker_processes` slot                                                                                      | Raise `max_worker_processes` or lower `cron.max_running_jobs`                                                                                                                   |

`nodename` is copied from `cron.host` when a job is scheduled ([job_metadata.c](https://github.com/citusdata/pg_cron/blob/v1.6.8/src/job_metadata.c#L270-L272)), so changing `cron.host` later doesn't move existing jobs.

## Two Ways to Get an Alert Out of the Database

A database that's down, in recovery, or missing its scheduler writes nothing, so whatever sends reports must be paired with a monitor that expects them on schedule and alerts when one doesn't arrive: a [dead man's switch](https://cronradar.com/blog/cron-dead-mans-switch). The report can come from inside the job or from outside.

|                                    | Outside reporter              | pg_net check-in in the job                             | `http` check-in in the job        |
| ---------------------------------- | ----------------------------- | ------------------------------------------------------ | --------------------------------- |
| Failed run                         | `fail`, within about a minute | Missed run, after the grace period                     | `fail`, from an exception handler |
| Run never starts, or database down | Missed run                    | Missed run                                             | Missed run                        |
| Job SQL changes                    | None                          | A check-in in every job                                | A wrapper in every job            |
| API key lives in                   | The reporter's host           | The database, and the request queue until sent         | The database                      |
| Check-in fails to send             | Retried next pass             | Logged in `net._http_response`; false missed-run alert | Up to your handler                |

### From Inside the Job

[pg_net](https://supabase.com/docs/guides/database/extensions/pg_net) (v0.20.5) queues requests in `net.http_request_queue` and sends them only after the transaction commits ([worker.c](https://github.com/supabase/pg_net/blob/v0.20.5/src/worker.c#L91-L119)). A job's command runs as one transaction in both of pg_cron's modes ([pg_cron.c](https://github.com/citusdata/pg_cron/blob/v1.6.8/src/pg_cron.c#L2233-L2244), [multi-statement queries](https://www.postgresql.org/docs/18/protocol-flow.html#PROTOCOL-FLOW-MULTI-STATEMENT)), so a check-in at the end is sent only if the work committed:

```sql
-- Supabase. Store the key once:
-- select vault.create_secret('ck_app_12345_abcdef', 'cronradar_api_key');
SELECT cron.schedule(
  'nightly-cleanup',
  '30 3 * * *',
  $$
  DELETE FROM sessions WHERE expires_at < now() - interval '30 days';
  SELECT net.http_get(
    url := 'https://cron.life/api/v1/ping/nightly-cleanup',
    params := jsonb_build_object('schedule', '30 3 * * *', 'gracePeriod', '1800'),
    headers := jsonb_build_object(
      'Authorization',
      'Basic ' || translate(
        encode(convert_to((SELECT decrypted_secret FROM vault.decrypted_secrets
                           WHERE name = 'cronradar_api_key') || ':', 'UTF8'), 'base64'),
        E'\n', '')
    ),
    timeout_milliseconds := 5000
  );
  $$
);
```

The key travels as the Basic auth username with an empty password, not in the URL, so it stays out of the URLs that proxies and request logs record ([HTTP API](https://docs.cronradar.com/api-reference)). `translate` removes the line breaks PostgreSQL's base64 output adds every 76 characters, which a CronRadar key is long enough to reach ([encode](https://www.postgresql.org/docs/18/functions-binarystring.html)).

That's a success heartbeat and nothing more: a `fail` sent from an exception handler that re-raises is rolled back with the job, and one that doesn't re-raise turns the failure into a success. A request that errors is recorded only in `net._http_response`, kept 6 hours by default, and the queue is an unlogged table that a crash empties ([pg_net docs](https://supabase.com/docs/guides/database/extensions/pg_net)). The decrypted key also waits in the queue, where Supabase notes any role that can log in can read it ([Supabase](https://supabase.com/docs/guides/functions/schedule-functions)).

The [`http`](https://github.com/pramsey/pgsql-http/blob/v1.7.2/README.md) extension (pgsql-http 1.7.2) is synchronous, so a `fail` sent from an exception handler has left before the rollback. The job waits on each request, up to a 5-second default timeout, and a timeout raises an error that fails the job unless you catch it.

The outside reporter is the reliable default: jobs stay plain SQL, failures arrive as failures, the API key stays out of the database, and it works anywhere you can connect. It needs a host to run on, and its reports trail each run by up to a minute.

## A Reporter for pg_cron Jobs

There's no CronRadar SDK for pg_cron and no auto-discovery of `cron.job`, and CronRadar's Python SDK isn't available right now, so this reporter uses HTTP check-ins: one CronRadar monitor per pg_cron job, keyed by job name.

### Convert Each Schedule

CronRadar reads five-field cron in UTC, without seconds, shortcuts, `$`, or `7` ([schedule syntax](https://docs.cronradar.com/schedules)). With `cron.timezone` on GMT, times carry over as written.

- `@hourly` is `0 * * * *`, `@daily` is `0 0 * * *`, `@weekly` is `0 0 * * 0`, `@monthly` is `0 0 1 * *`, and `7` becomes `0`.
- `$` has no five-field form. Schedule the job on `28-31` and have its SQL do nothing unless tomorrow is the 1st, so every scheduled day produces a run.
- With both day fields restricted, pg_cron runs on either by default and CronRadar expects both. Split the job in two, or turn on 1.6.8's setting.
- A `'30 seconds'` job gets `* * * * *`: the monitor then shows the job is still running each minute, not that every run succeeded.
- `@reboot` has no schedule to check.

Set each grace period to the job's usual duration plus two minutes, since reports trail runs by up to a minute.

### The Reporter

```python
#!/usr/bin/env python3
# pgcron_report.py (Python 3.10+, psycopg 3.3): report each pg_cron job's newest
# finished run to CronRadar as a complete or fail check-in. Run it every minute.
import base64
import json
import os
import sys
import urllib.parse
import urllib.request
from pathlib import Path

import psycopg

# pg_cron job name: (CronRadar schedule in UTC, grace period in seconds).
MONITORS = {
    "nightly-cleanup": ("30 3 * * *", 1800),
    "refresh-sales-rollup": ("*/15 * * * *", 600),
}
STATE = Path("/var/lib/pgcron-report/state.json")
AUTH = "Basic " + base64.b64encode(f"{os.environ['CRONRADAR_API_KEY']}:".encode()).decode()

NEWEST_FINISHED_RUNS = """
    SELECT DISTINCT ON (d.jobid) j.jobname, d.runid, d.status, d.return_message
    FROM cron.job_run_details d
    JOIN cron.job j ON j.jobid = d.jobid
    WHERE j.jobname = ANY(%s) AND d.status IN ('succeeded', 'failed')
    ORDER BY d.jobid, d.runid DESC
"""


def checkin(key, event, runid, params):
    """Send one check-in with a 5-second timeout. True if CronRadar accepted it."""
    query = urllib.parse.urlencode(params, quote_via=urllib.parse.quote)
    request = urllib.request.Request(
        f"https://cron.life/api/v1/ping/{urllib.parse.quote(key, safe='')}/{event}?{query}",
        method="POST",
    )
    request.add_header("Authorization", AUTH)
    request.add_header("Idempotency-Key", f"pgcron-{runid}")
    try:
        urllib.request.urlopen(request, timeout=5).close()
        return True
    except Exception as exc:
        print(f"CronRadar {event} for {key} failed: {exc}", file=sys.stderr)
        return False


def main():
    reported = json.loads(STATE.read_text()) if STATE.exists() else {}
    with psycopg.connect(os.environ["PGCRON_DATABASE_URL"], connect_timeout=10) as conn:
        rows = conn.execute(NEWEST_FINISHED_RUNS, [list(MONITORS)]).fetchall()

    for jobname, runid, status, message in rows:
        if runid <= reported.get(jobname, 0):
            continue  # reported on an earlier pass
        schedule, grace = MONITORS[jobname]
        params = {"schedule": schedule, "gracePeriod": grace, "run_id": f"pgcron-{runid}"}
        if status == "succeeded":
            sent = checkin(jobname, "complete", runid, params)
        else:
            lines = (message or "").strip().splitlines()
            params["message"] = (lines[0] if lines else "pg_cron run failed")[:200]
            sent = checkin(jobname, "fail", runid, params)
        if sent:
            reported[jobname] = runid  # an unsent report is retried next pass

    tmp = STATE.with_suffix(".tmp")
    tmp.write_text(json.dumps(reported))
    tmp.replace(STATE)


if __name__ == "__main__":
    main()
```

- **Each finished run is sent once.** The state file keeps the last `runid` reported per job, and run IDs only grow. A report that fails to send is retried next pass, and the `Idempotency-Key` header makes CronRadar ignore a repeat of one it already accepted.
- **Monitors heal themselves.** Every check-in carries the schedule, so the first creates the monitor and a deleted one comes back on the next. `gracePeriod` applies only when a check-in creates the monitor; change it later in the dashboard.
- **A `fail` alerts immediately**, without the grace period. The message is the first line of `return_message`, capped at 200 characters; that line can quote an input value, so send a fixed message for jobs that touch personal data.
- **Connect as the role that scheduled the jobs** or one that sees every role's rows, with `USAGE ON SCHEMA cron`, in the database that holds `cron.job`. psycopg sends the Python list as an array for `= ANY` ([psycopg](https://www.psycopg.org/psycopg3/docs/basic/adapt.html#lists-adaptation)). If the database is unreachable, the script exits with an error and sends nothing.

### Run It Every Minute

Create a virtual environment (`python3 -m venv /opt/pgcron-report/venv`), install `'psycopg[binary]==3.3.6'` into it, put the script in `/opt/pgcron-report/`, and write `/etc/pgcron-report.env`, readable only by root, with `CRONRADAR_API_KEY` and `PGCRON_DATABASE_URL` (a libpq connection URI including the reporter's password).

```ini
# /etc/systemd/system/pgcron-report.service
[Unit]
Description=Report pg_cron runs to CronRadar

[Service]
Type=oneshot
DynamicUser=yes
StateDirectory=pgcron-report
EnvironmentFile=/etc/pgcron-report.env
ExecStart=/opt/pgcron-report/venv/bin/python /opt/pgcron-report/pgcron_report.py
```

```ini
# /etc/systemd/system/pgcron-report.timer
[Unit]
Description=Run pgcron-report every minute

[Timer]
OnCalendar=minutely
AccuracySec=1s

[Install]
WantedBy=timers.target
```

Start it with `systemctl enable --now pgcron-report.timer`. `DynamicUser=yes` runs the script under a throwaway account, and `StateDirectory=` gives it `/var/lib/pgcron-report` for its state ([systemd.exec](https://www.freedesktop.org/software/systemd/man/latest/systemd.exec.html)); `minutely` fires at second zero of each minute ([systemd.time](https://www.freedesktop.org/software/systemd/man/latest/systemd.time.html)).

### What It Can and Can't See

- **It reports when a run finished.** CronRadar times the check-in, which trails the run by up to a minute; the grace period absorbs that.
- **A run that never starts leaves no row**, so nothing is sent and CronRadar notices the silence. Its overdue check runs once a minute: the monitor turns `warning` once the expected time passes, and `critical`, with an incident, once it's past the grace period and at least two minutes late. A stopped scheduler, a standby, a dead database, a dead reporter, and a broken network all look alike; you learn that something is wrong, not which.
- **It sends no `start`,** so there's no stuck detection. A hung run never finishes and holds back later runs, which shows up as a missed run.
- **Only the newest run is reported.** When two runs of a job finish between passes, such as catch-up runs after an overrun, an earlier failure can hide behind a later success.
- **A monitor appears with its job's first finished run.** To catch a job that never runs at all, create the monitor in the dashboard first: it stays `pending`, and CronRadar sends a never-executed alert once the first expected run plus the grace period passes.
- **`succeeded` means the SQL committed.** A cleanup that deleted nothing reports `DELETE 0`, and a Supabase job calling an Edge Function through `net.http_post` succeeds once the request is queued, whatever the function returns; check in from the function itself. [Silent success in scheduled jobs](https://cronradar.com/blog/scheduled-job-silent-success) covers reporting counts as outcome checks.
- **Rate limits are per API key:** 60 check-ins a minute and 1,000 an hour, shared by every job the reporter covers.

## Frequently Asked Questions

### What time zone does pg_cron use?

GMT, unless `cron.timezone` (pg_cron 1.5.0 and later) names another zone; changing it takes a restart. `SHOW cron.timezone;` confirms it, for a role allowed to read pg_cron's settings. Under a daylight saving zone, jobs inside the changeover hour are skipped or doubled.

### Why is my pg_cron job not running?

Check that `shared_preload_libraries` includes `pg_cron`, that the server isn't a standby, and that the job is active and you're reading its schedule in GMT. Then read its newest row in `cron.job_run_details`: `connection failed` points to `pg_hba.conf`, and `could not start background process` to `max_worker_processes`. No rows for any job means the scheduler isn't running or `cron.log_run` is off.

### How do I see pg_cron job history?

Query `cron.job_run_details`, joined to `cron.job` for names, and sort by `runid`. On Supabase, each job's History button in the Cron integration shows the same rows. The table is never cleaned automatically, so schedule a delete of old rows.

### Does pg_cron run on a read replica?

No. The scheduler starts only when the server is in read-write mode, so a standby holds the jobs but runs none. After promotion, it starts running them and marks runs that were in progress as failed with `server restarted`; runs due during the failover aren't made up.

### How do I get alerted when a pg_cron job fails?

pg_cron doesn't alert. Run a process outside the database that sends each job's newest finished run to a heartbeat monitor as `complete` or `fail`, as the reporter above does with CronRadar. Because the monitor expects a report on each job's schedule, it also alerts when a run never happens, including when the database is down.
