Monitoring pg_cron Jobs in PostgreSQL and Supabase
Updated
Contents
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 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). 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).
-- 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), 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). cron.unschedule('nightly-cleanup') deletes it and keeps its history (Supabase Cron).
| 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+ | 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 | The cloudsql.enable_pg_cron flag |
Background workers only; pg_cron 1.6.4 |
| Azure flexible server | Allowlist it, add it to shared_preload_libraries, restart |
cron.schedule_in_database with a username isn't supported |
| Supabase | 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). 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, 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). AWS, Azure, and Supabase all write their examples in GMT. The scheduler steps through real minutes and converts each to that zone (ShouldRunTask), 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). status goes from starting (plus connecting and sending in connection mode) to running, then succeeded or failed (job_metadata.c). A success's return_message is the command tag, such as DELETE 4182; a failure's is the error.
-- 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): server restarted (the run was in progress when the server stopped, job_metadata.c), 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), 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:
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). Seconds-interval jobs queue at most one (pg_cron.c). Make jobs safe to run twice; idempotent scheduled jobs shows how.
- No catch-up. The scheduler starts counting from the current minute when it starts (pg_cron.c), so runs due while the server was down or failing over never happen. Missed runs after downtime compares other schedulers.
- Standbys run nothing. The scheduler starts only in normal read-write state (pg_cron.c, background workers), so a read replica holds the jobs but runs them only once promoted.
- A concurrency cap.
cron.max_running_jobsdefaults to 32 in connection mode and 5 with background workers, lowered whenmax_connections, ormax_worker_processesminus one, is smaller (pg_cron.c). 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), and background-worker runs apply the timeout to the whole command (pg_cron.c). 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.
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), 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 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), 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. 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 (v0.20.5) queues requests in net.http_request_queue and sends them only after the transaction commits (worker.c). A job's command runs as one transaction in both of pg_cron's modes (pg_cron.c, multi-statement queries), so a check-in at the end is sent only if the work committed:
-- 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). translate removes the line breaks PostgreSQL's base64 output adds every 76 characters, which a CronRadar key is long enough to reach (encode).
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). The decrypted key also waits in the queue, where Supabase notes any role that can log in can read it (Supabase).
The http 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). With cron.timezone on GMT, times carry over as written.
@hourlyis0 * * * *,@dailyis0 0 * * *,@weeklyis0 0 * * 0,@monthlyis0 0 1 * *, and7becomes0.$has no five-field form. Schedule the job on28-31and 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. @reboothas 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#
#!/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
runidreported per job, and run IDs only grow. A report that fails to send is retried next pass, and theIdempotency-Keyheader 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.
gracePeriodapplies only when a check-in creates the monitor; change it later in the dashboard. - A
failalerts immediately, without the grace period. The message is the first line ofreturn_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 holdscron.job. psycopg sends the Python list as an array for= ANY(psycopg). 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).
# /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# /etc/systemd/system/pgcron-report.timer
[Unit]
Description=Run pgcron-report every minute
[Timer]
OnCalendar=minutely
AccuracySec=1s
[Install]
WantedBy=timers.targetStart 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); minutely fires at second zero of each minute (systemd.time).
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
warningonce the expected time passes, andcritical, 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. succeededmeans the SQL committed. A cleanup that deleted nothing reportsDELETE 0, and a Supabase job calling an Edge Function throughnet.http_postsucceeds once the request is queued, whatever the function returns; check in from the function itself. Silent success in scheduled jobs 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.
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.