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_cron is in shared_preload_libraries. Remove it and restart: the jobs stay in cron.job, none of them runs, and the server log says nothing about it.
  • cron.job_run_details only records runs that started. A scheduler that is not loaded, a cron.database_name pointing 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.timezone is 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 with division by zero and sent no ping.
  • A job running INSERT ...; SELECT http_get(...) against a closed port failed, and the INSERT was 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 logged WARNING: 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 ... SET and a function-level SET were all ignored in a fresh session on version 1.7.2, while http_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
fi

Run 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.