To monitor pg_cron, give each job its own heartbeat URL and ping it as the last statement of the job, so the ping only goes out when everything before it succeeded. If the job fails, hangs, or never starts, no ping arrives and you get an alert. The trap is that pg_cron keeps its history in the database it schedules: when the scheduler does not start (a lost shared_preload_libraries entry, a failover, cron.database_name pointing elsewhere), cron.job_run_details simply stops growing, and every query you run against it looks clean.
I'm Léo, I build Hyperping, and this guide covers the ways pg_cron fails without an error, what you can check from SQL, and the setup I recommend with Hyperping healthchecks. I tested everything below on PostgreSQL 18.6 with pg_cron 1.6.8 (the current release, September 2026) and the http extension 1.7.2, installed from the PGDG apt repository on Ubuntu 22.04, against a local HTTP listener. I could not run pg_net or the managed services (RDS, Cloud SQL, Azure, Supabase, Neon), so those parts rely on their documentation, linked where they come up.
Key takeaways
- pg_cron only runs when
pg_cronis inshared_preload_libraries. Remove it and restart: the jobs stay incron.job, none of them runs, and the server log says nothing about it. cron.job_run_detailsonly records runs that started. A scheduler that is not loaded, acron.database_namepointing at another database, or a standby all leave it silent, so a query on it cannot alert on them.- A multi-statement job runs as one transaction. A ping placed last is skipped when the work fails, but a ping that fails rolls back the work, so wrap it in a function that catches its own errors.
- Schedules are in GMT unless
cron.timezoneis set, and that setting covers every job on the server. - Monitor from outside the database: one healthcheck per job with the same expression and timezone, plus a five-minute heartbeat job for the scheduler itself.
How pg_cron fails silently
pg_cron is a background worker, the pg_cron launcher, that reads the cron.job table, opens a connection (or a background worker) for each run, and records the result in cron.job_run_details. When a run fails, you get a failed row that nobody reads. When the launcher is missing or looking at the wrong database, you get no row at all.
The scheduler does not start after a config change
The pg_cron README is explicit: "To start the pg_cron background worker, you need to add pg_cron to shared_preload_libraries in postgresql.conf." Nothing checks that it stays there. A new parameter group on RDS, a config management run that sets shared_preload_libraries = 'pg_stat_statements' and drops the rest of the list, or a major upgrade onto a cluster with a default config all remove it.
I removed it on purpose and restarted the server. The jobs were still listed in cron.job with active = t, no pg_cron launcher appeared in pg_stat_activity, no new row reached cron.job_run_details, and my heartbeat job stopped pinging. Apart from the old launcher being stopped at shutdown, the server log showed a normal start and nothing about pg_cron. The only loud failure was creating a new job:
ERROR: pg_cron can only be loaded via shared_preload_libraries
HINT: Add pg_cron to the shared_preload_libraries configuration variable in postgresql.conf.If you edit this setting with ALTER SYSTEM, use ALTER SYSTEM RESET or a full list. ALTER SYSTEM SET shared_preload_libraries = '' wrote '""' to postgresql.auto.conf on PostgreSQL 18.6, and the server refused to start with could not access file "".
cron.database_name points at another database
The README says the background worker expects its metadata tables in the postgres database unless you set cron.database_name, and that "pg_cron may only be installed to one database in a cluster". Creating the extension in the wrong database fails loudly:
ERROR: can only create extension in database postgres
DETAIL: Jobs must be scheduled from the database configured in cron.database_name, since the pg_cron background worker reads job descriptions from this database.The silent version is the opposite move: the extension lives in postgres, and someone later sets cron.database_name = 'app'. I did that and restarted. The launcher connected to app, logged pg_cron scheduler started, and ran nothing. The source explains why: "If the pg_cron extension has not been created yet or we are on a hot standby, the job table is treated as being empty."
Standbys run nothing, and failover depends on the replica's config
From the README: pg_cron "does not run any jobs" while the server is in hot standby mode, "but it automatically starts when the server is promoted". The launcher is registered at server start, so the replica needs pg_cron in its own shared_preload_libraries before promotion. The jobs themselves are replicated with the rest of the database. On a self-managed replica built from a different config, a failover leaves you with a new primary, all your jobs listed, and no scheduler.
For managed services, an AWS blog post from 2021 says Multi-AZ failover has "no impact to the scheduled pg_cron jobs" and that a promoted read replica runs them. I did not test failover, and the current RDS docs do not cover it.
Connection and worker limits fail runs before they start
By default, pg_cron "uses libpq to open a new connection to the local database, which needs to be allowed by pg_hba.conf" (README). With the default pg_hba.conf of the Debian and Ubuntu packages, connections to localhost need a password, and my first job failed in 10 milliseconds with a return_message of connection failed. Setting cron.host = '/var/run/postgresql' (the Unix socket directory) fixed it.
With cron.use_background_workers = on, each run needs a free slot in max_worker_processes. I set it to 3 and scheduled three 20-second jobs on the same minute: one ran, the other two failed after 10 seconds with could not start background process; more details may be available in the server log, and the log said out of background worker slots. Cloud SQL only supports this background worker mode.
cron.max_running_jobs caps concurrent runs: 32 by default with libpq connections, and with background workers 5 or max_worker_processes - 1, whichever is lower. Jobs above the cap wait their turn.
Long runs queue up instead of overlapping
The README: "pg_cron can run multiple jobs in parallel, but only one instance of each specific job at a time. If a second instance is triggered before the first finishes, it's queued and starts as soon as the first one completes." My SELECT pg_sleep(100) job scheduled every minute ran from 20:11:00 to 20:12:40, and the 20:12 run started at 20:12:40. With a cron schedule, every tick that passes during a run adds one more run to the queue, so a job that is always slower than its schedule runs back to back. For interval schedules, the scheduler code queues at most one extra run, then goes back to the regular cadence.
A job that hangs holds its place forever, so set a timeout inside it. SET statement_timeout = '5s'; SELECT pg_sleep(30) as a job command failed after 5 seconds with ERROR: canceling statement due to statement timeout.
"succeeded" only means no error
A run is succeeded when no statement raised an error. My DELETE FROM items WHERE note = 'nothing' job succeeded every minute with DELETE 0. For a multi-statement command, return_message only holds the result of the last statement, such as 1 row. If an empty result means something broke upstream, make the job raise:
SELECT cron.schedule('daily-rollup', '0 1 * * *', $$
DO $do$
DECLARE
n bigint;
BEGIN
INSERT INTO daily_rollup (day, orders)
SELECT created_at::date, count(*) FROM orders
WHERE created_at >= current_date - 1 AND created_at < current_date
GROUP BY 1;
GET DIAGNOSTICS n = ROW_COUNT;
IF n = 0 THEN
RAISE EXCEPTION 'daily-rollup inserted no rows';
END IF;
END
$do$;
$$);Schedules run in GMT unless cron.timezone says otherwise
pg_cron used GMT only until version 1.5 added cron.timezone (changelog). It is a server-wide setting that needs a restart, and there is no per-job timezone. With cron.timezone = 'Europe/Paris', my job scheduled 16 22 * * * ran at 20:16 UTC, and a twin scheduled 16 20 * * * did not run. Neon interprets every schedule in UTC and ignores the setting. I did not test daylight saving transitions: keep critical jobs out of the 1:00 to 3:00 window if you set a timezone that has them.
The history table grows, and nobody reads it
"The records in the table are not cleaned automatically" (README), and the RDS docs recommend scheduling a purge. A job every 10 seconds adds 8,640 rows a day. Purge it with pg_cron itself:
SELECT cron.schedule('purge-cron-history', '0 12 * * *',
$$DELETE FROM cron.job_run_details WHERE end_time < now() - interval '7 days'$$);How to check pg_cron with its own tools
Everything pg_cron knows is in two tables and pg_stat_activity:
-- Jobs, schedules and the database each one runs in
SELECT jobid, jobname, schedule, active, database, username FROM cron.job ORDER BY jobid;
-- Failed runs in the last 24 hours, with the error
SELECT j.jobname, d.status, d.return_message, d.start_time
FROM cron.job_run_details d
JOIN cron.job j ON j.jobid = d.jobid
WHERE d.status = 'failed' AND d.start_time > now() - interval '24 hours'
ORDER BY d.start_time DESC;
-- Active jobs with no successful run in the last 25 hours
SELECT j.jobid, j.jobname, j.schedule,
max(d.start_time) AS last_start,
max(d.end_time) FILTER (WHERE d.status = 'succeeded') AS last_success
FROM cron.job j
LEFT JOIN cron.job_run_details d ON d.jobid = j.jobid
WHERE j.active
GROUP BY j.jobid, j.jobname, j.schedule
HAVING max(d.end_time) FILTER (WHERE d.status = 'succeeded') IS NULL
OR max(d.end_time) FILTER (WHERE d.status = 'succeeded') < now() - interval '25 hours';
-- The scheduler itself: one row, or pg_cron is not running
SELECT pid, backend_type, application_name, datname, backend_start
FROM pg_stat_activity
WHERE backend_type = 'pg_cron launcher';
-- pg_cron settings
SELECT name, setting FROM pg_settings WHERE name LIKE 'cron.%' ORDER BY name;The launcher shows up with backend_type = 'pg_cron launcher' and application_name = 'pg_cron scheduler', and in the default libpq mode each running job is a client backend named pg_cron. A status is one of starting, connecting, sending, running, succeeded or failed, and runs that were in progress during a restart are marked failed with server restarted. With cron.log_statement = on (the default), the server log also gets a cron job 2 starting: ... and a cron job 2 completed: 1 row line per run.
All of this lives inside the database pg_cron runs in. When the launcher is not loaded, the job list still looks healthy and the history just stops. The "no success in 25 hours" query above does catch that, but only if something outside the database runs it and alerts on the result, which is what a heartbeat does.
How to monitor pg_cron with Hyperping
A Hyperping healthcheck is a secret URL that expects a request on a schedule. When the request does not arrive by the expected time plus a grace period, it opens an incident and alerts you, then resolves on the next successful ping. Healthchecks are included on every plan, Free included.
1. Create one healthcheck per job with the same schedule
In Hyperping, open Healthchecks, click Create healthcheck, name it after the job, and pick Cron. Use the job's expression and the timezone pg_cron uses: UTC, unless cron.timezone is set, in which case use the same IANA name. SHOW cron.timezone; tells you which.
| pg_cron schedule | Hyperping schedule | Generator page |
|---|---|---|
*/5 * * * * |
Cron */5 * * * * |
every 5 minutes |
0 * * * * |
Cron 0 * * * * |
every hour |
0 0 * * * |
Cron 0 0 * * * |
every day |
30 3 * * 6 |
Cron 30 3 * * 6 |
|
0 12 $ * * (last day of the month) |
Cron 0 12 L * * |
|
30 seconds, 10 seconds |
Simple, every 1 minute |
The $ for the last day of the month is pg_cron syntax; write it as L in Hyperping. Interval schedules in seconds need care: a healthcheck accepts at most 10 pings a minute, so a job every 5 seconds cannot ping on every run. Step 3 shows a one-minute ping for those.
Each healthcheck gives you a URL like https://hc.hyperping.io/tok_....
2. Store the ping URLs in a table, not in the job command
With cron.log_statement on, pg_cron writes each job's command to the server log before running it, and cron.job_run_details keeps a copy of it too. A URL written into the command ends up in both. Keep the URLs in a table and refer to them by name:
CREATE SCHEMA hyperping;
CREATE TABLE hyperping.healthchecks (
job_name text PRIMARY KEY,
ping_url text NOT NULL
);
INSERT INTO hyperping.healthchecks (job_name, ping_url) VALUES
('purge-sessions', 'https://hc.hyperping.io/tok_your_purge_sessions_token'),
('nightly-rollup', 'https://hc.hyperping.io/tok_your_nightly_rollup_token'),
('process-updates', 'https://hc.hyperping.io/tok_your_process_updates_token'),
('pg-cron-heartbeat', 'https://hc.hyperping.io/tok_your_heartbeat_token');Create the schema as the role that owns the jobs. A new schema grants nothing to PUBLIC, so other roles cannot read the tokens.
3. Ping on success with the http extension
The http extension (pgsql-http) sends requests synchronously from SQL. Install it with apt-get install postgresql-18-http from PGDG, then CREATE EXTENSION http; in the pg_cron database. Supabase offers it too. The RDS, Aurora, Cloud SQL, Azure and Neon extension lists do not include it: use step 4 or step 5 there.
CREATE OR REPLACE FUNCTION hyperping.ping(job text, suffix text DEFAULT '')
RETURNS void
LANGUAGE plpgsql
AS $$
DECLARE
url text;
BEGIN
SELECT ping_url INTO url FROM hyperping.healthchecks WHERE job_name = job;
IF url IS NULL THEN
RAISE WARNING 'no Hyperping URL for job %', job;
RETURN;
END IF;
-- 10 second timeout. SET http.curlopt_timeout_ms is ignored before
-- the first request of a session, which is every pg_cron run.
PERFORM http_set_curlopt('CURLOPT_TIMEOUT_MS', '10000');
PERFORM http_get(url || suffix);
EXCEPTION WHEN OTHERS THEN
-- A failed ping must not roll back the job's work
RAISE WARNING 'Hyperping ping for % failed: %', job, SQLERRM;
END;
$$;Then end each job with the ping:
SELECT cron.schedule('purge-sessions', '*/5 * * * *', $$
DELETE FROM sessions WHERE expires_at < now();
SELECT hyperping.ping('purge-sessions');
$$);
SELECT cron.schedule('nightly-rollup', '0 1 * * *', $$
SELECT hyperping.ping('nightly-rollup', '/start');
CALL refresh_daily_rollup();
SELECT hyperping.ping('nightly-rollup');
$$);pg_cron runs the whole command as one transaction (unless it contains its own COMMIT), and I checked what that means on 1.6.8:
- A job running
SELECT 1/0; SELECT hyperping.ping(...)failed withdivision by zeroand sent no ping. - A job running
INSERT ...; SELECT http_get(...)against a closed port failed, and theINSERTwas rolled back with it. Without the exception handler, a Hyperping outage or a DNS hiccup on your database server would undo the job's work. - The same job calling
hyperping.ping()succeeded, kept its row, and loggedWARNING: Hyperping ping for closed-port failed: Failed to connect to 127.0.0.1 port 18098. - The extension's default timeout is 5 seconds.
SET http.curlopt_timeout_ms,ALTER DATABASE ... SETand a function-levelSETwere all ignored in a fresh session on version 1.7.2, whilehttp_set_curlopt()applied.
The /start call in nightly-rollup is sent right away, before the work: it opens a run in Hyperping without moving the deadline, and the final ping records the duration. If the procedure fails, the transaction rolls back, but /start has already gone out and no success ping follows, so the healthcheck goes down after the grace period.
For a job on a seconds interval, ping once a minute from a separate job, and only when the fast job succeeded in the last minute. Give its healthcheck simple mode, every 1 minute:
SELECT cron.schedule('process-updates', '5 seconds', 'CALL process_updates()');
SELECT cron.schedule('process-updates-ping', '* * * * *', $$
SELECT hyperping.ping('process-updates')
WHERE EXISTS (
SELECT 1 FROM cron.job_run_details d
JOIN cron.job j ON j.jobid = d.jobid
WHERE j.jobname = 'process-updates'
AND d.status = 'succeeded'
AND d.end_time > now() - interval '1 minute'
);
$$);4. Use pg_net on Supabase
Supabase Cron runs on pg_cron, and Supabase's pg_net is the usual way to send HTTP from it. It works differently from the http extension: net.http_get inserts the request into a queue table, and a background worker sends it. The Supabase docs say "HTTP requests are not started until the transaction is committed", so a job that fails before the end rolls back the queued ping with everything else, and a failed ping never fails the job.
SELECT cron.schedule('purge-sessions', '*/5 * * * *', $$
DELETE FROM sessions WHERE expires_at < now();
SELECT net.http_get(
url := (SELECT ping_url FROM hyperping.healthchecks WHERE job_name = 'purge-sessions'),
timeout_milliseconds := 10000
);
$$);pg_net has no HEAD method, which does not matter since Hyperping accepts GET. Responses land in net._http_response for 6 hours by default if you need to check one. I could not test pg_net myself, so this section relies on the Supabase docs and the pg_net source. Supabase also caps Cron at 8 concurrent jobs and asks that each job run no more than 10 minutes.
5. Use an external watchdog on RDS, Cloud SQL and Azure
Amazon RDS and Aurora, Cloud SQL and Azure Database for PostgreSQL support pg_cron but list neither http nor pg_net. There, a small script on a machine that can reach the database reads cron.job_run_details and pings when it finds a recent success:
#!/usr/bin/env bash
# /usr/local/bin/pg-cron-watchdog.sh <job_name> <max_age> <ping_url>
# Pings <ping_url> if <job_name> succeeded within <max_age>.
set -u
JOB="$1"; MAX_AGE="$2"; URL="$3"
ok=$(psql "$DATABASE_URL" -XAtq -v job="$JOB" -v max_age="$MAX_AGE" <<'SQL'
SELECT count(*)
FROM cron.job_run_details d
JOIN cron.job j ON j.jobid = d.jobid
WHERE j.jobname = :'job'
AND d.status = 'succeeded'
AND d.end_time > now() - :'max_age'::interval;
SQL
) || { echo "watchdog: query failed" >&2; exit 1; }
if [ "${ok:-0}" -gt 0 ]; then
curl -fsS -m 10 --retry 3 -o /dev/null "$URL" || echo "watchdog: ping failed" >&2
else
echo "watchdog: no successful run of $JOB in the last $MAX_AGE" >&2
fiRun it shortly after the job's usual end time, and give the healthcheck the watchdog's schedule. For nightly-rollup at 01:00 UTC, which takes about 10 minutes:
# crontab of the watchdog host, in UTC
DATABASE_URL=postgresql://cron_owner@db.example.com:5432/postgres?sslmode=require
NIGHTLY_ROLLUP_URL=https://hc.hyperping.io/tok_your_nightly_rollup_token
30 1 * * * /usr/local/bin/pg-cron-watchdog.sh nightly-rollup '45 minutes' "$NIGHTLY_ROLLUP_URL"The healthcheck then uses cron 30 1 * * * in UTC. Connect as the role that owns the jobs: pg_cron's row level security only shows a user their own jobs and runs, unless the user is a superuser or has bypassrls. Keep the password in ~/.pgpass rather than in the URL. If the watchdog host goes down, its pings stop too, so you hear about that as well. To run the script from a systemd timer instead of cron, see how to monitor systemd timers.
6. Add a heartbeat job for the scheduler
A weekly job only notices a missing scheduler a week later. Add a job that does nothing but ping, every five minutes:
SELECT cron.schedule('pg-cron-heartbeat', '*/5 * * * *',
$$SELECT hyperping.ping('pg-cron-heartbeat')$$);Create its healthcheck in simple mode, every 5 minutes, with a 5 minute grace period. In my tests, this ping stopped right after the restart without pg_cron in shared_preload_libraries, and right after the restart with cron.database_name pointing at another database, while every other check stayed quiet. Without an HTTP extension, schedule SELECT 1 every five minutes and run the watchdog every five minutes with a '10 minutes' window.
7. Size the grace period and route the alerts
In cron mode, Hyperping expects the success ping by the scheduled time plus the grace period, and the ping goes out when the job ends. The grace period has to cover the run time, plus the queue when runs back up. pg_cron already measures it:
SELECT j.jobname,
max(d.end_time - d.start_time) AS longest,
avg(d.end_time - d.start_time) AS average
FROM cron.job_run_details d
JOIN cron.job j ON j.jobid = d.jobid
WHERE d.status = 'succeeded' AND d.start_time > now() - interval '7 days'
GROUP BY j.jobname
ORDER BY longest DESC;Set the grace period to roughly twice the longest run. The default is 10 minutes, and the minimum is 1.
A missed ping goes to every channel connected to the project at once: email and SMS to every member, Slack, Discord, Telegram, PagerDuty and Opsgenie. Healthchecks do not use escalation policies or on-call schedules, so if a failed nightly job has to wake someone, send the alert to PagerDuty or Opsgenie and let their rotation decide who gets paged.
Test it before you trust it
On staging, create a test healthcheck in simple mode, every 1 minute, with a 1 minute grace period, and schedule a copy of a job that pings it:
INSERT INTO hyperping.healthchecks (job_name, ping_url)
VALUES ('purge-sessions-test', 'https://hc.hyperping.io/tok_your_test_token');
SELECT cron.schedule('purge-sessions-test', '* * * * *', $$
DELETE FROM sessions WHERE expires_at < now();
SELECT hyperping.ping('purge-sessions-test');
$$);Within a minute, the ping shows up in the healthcheck's last pings. The user agent tells you which path sent it: the http extension sends the server's version string (PostgreSQL 18.6 ...), pg_net sends pg_net/<version>, and the watchdog sends curl/<version>. Then unschedule the copy and schedule it again with SELECT 1/0; in front of the DELETE: cron.job_run_details shows a failed run, no ping arrives, and the alert reaches you once the grace period passes. Put the working command back, and the incident closes on the next ping. Clean up with SELECT cron.unschedule('purge-sessions-test');.
Test the scheduler too: on a staging server, take pg_cron out of shared_preload_libraries and restart. The heartbeat healthcheck should go down within ten minutes.
If your jobs also run outside the database, the same pattern covers systemd timers, Kubernetes CronJobs and Vercel cron jobs. For a side by side of heartbeat tools, see the best cron job monitoring tools. pg_cron is not a backup tool: monitoring database backups covers the dumps that run outside the database.
FAQ
Why is my pg_cron job not running? ▼
First check that the scheduler exists: `SHOW shared_preload_libraries` must include `pg_cron`, and `SELECT pid FROM pg_stat_activity WHERE backend_type = 'pg_cron launcher'` must return a row. Without it no job runs and nothing is logged. Then check that `cron.database_name` names the database where you ran `CREATE EXTENSION pg_cron`, that the server is not a standby, that the job is `active` in `cron.job`, and look in `cron.job_run_details` for `connection failed` or `could not start background process`.
How do I see failed pg_cron jobs? ▼
Query `cron.job_run_details`, joined to `cron.job` for the job name: `WHERE status = 'failed'` lists the failed runs, and `return_message` holds the error, such as `ERROR: division by zero` or `server restarted`. The table only records runs that started, so it cannot show a job that never ran because the scheduler was down.
What timezone does pg_cron use? ▼
GMT by default. Since pg_cron 1.5 you can set `cron.timezone` in postgresql.conf, for example `cron.timezone = 'Europe/Paris'`, then restart: it applies to every job on the server, there is no per-job timezone. Neon documents that schedules are always interpreted in UTC and that changing `cron.timezone` has no effect there.
Does pg_cron run on a read replica? ▼
No. The pg_cron README says it does not run any jobs while the server is in hot standby, and starts automatically when the server is promoted. The job table is replicated, but the promoted server only starts the scheduler if its own configuration has `pg_cron` in `shared_preload_libraries`.
What happens if a pg_cron job takes longer than its schedule? ▼
pg_cron runs one instance of a job at a time and queues the next one, which starts as soon as the previous run ends. I tested a `pg_sleep(100)` job scheduled every minute on pg_cron 1.6.8: the 20:11 run ended at 20:12:40 and the 20:12 run started at the same second. Jobs never overlap, but a job that is always slower than its schedule runs back to back.
Can pg_cron call a URL when a job succeeds? ▼
Yes, with an HTTP extension. The http extension (pgsql-http) sends the request synchronously inside the job, and pg_net on Supabase queues it and sends it after the transaction commits. Amazon RDS, Aurora, Cloud SQL and Azure Database for PostgreSQL list neither extension, so there you read `cron.job_run_details` from an external script and ping from that script.
How do I clean up cron.job_run_details? ▼
The table is never purged automatically. Schedule a pg_cron job such as `SELECT cron.schedule('purge-cron-history', '0 12 * * *', $$DELETE FROM cron.job_run_details WHERE end_time < now() - interval '7 days'$$)`, or set `cron.log_run = off` if you do not want run history at all. A job every 10 seconds adds 8,640 rows a day.




