The scheduler moved into the database
For years the scheduled job lived on a host: a crontab line, a systemd timer, a script with an exit code. Now every managed Postgres ships pg_cron as a one-click extension. PlanetScale enabled it for all Postgres databases on 15 August 2025, and RDS, Aurora, Supabase and Neon had it earlier. Teams that never ran a crontab now schedule work with a single SQL call.
The pitch is real: you schedule SQL from SQL, and the job runs where the data lives, no host to patch. But it runs in the database process now. It inherits the database's failure modes and loses the OS tooling you leaned on. These cluster where host cron never had them.
How pg_cron actually runs
Where the jobs live
pg_cron keeps its metadata in one database, postgres by default, set by cron.database_name. It needs shared_preload_libraries = 'pg_cron' and a restart to load. A background worker then wakes every minute, reads the cron.job table, and dispatches anything due. Jobs can target other databases, but the schedule lives only there.
One job, one worker
Each running job takes a background worker. cron.max_running_jobs caps how many run at once, defaulting to 32, and the workers come out of max_worker_processes. A job fails outright if it cannot get a worker, so a schedule that looks fine breaks once enough jobs overlap and the pool empties. pg_cron also runs one instance of a given job at a time; a run triggered while the last is still going queues behind it, so a slow job becomes a backlog.
The failure modes host cron never had
It stops on the standby
pg_cron does not run jobs on a hot standby, and it starts when the server is promoted. That default stops a replica from double-running every job. The trap is the promotion: the new primary only schedules anything if pg_cron is in its shared_preload_libraries too. Build a replica without that setting and failover completes, everything looks healthy, and not one job runs again. Nothing errors, because nothing is trying.
Jobs that were mid-run at failover
Even with the extension present, in-flight state can survive a failover in a broken form. TimescaleDB, whose job scheduler lives in the database the same way, hit exactly this. Issue 9360, opened on 4 March 2026, describes refresh jobs that were running when the primary crashed: the replica inherits a row with last_finish at minus infinity, and after promotion nothing sanitizes it. The job lands with next_start at minus infinity, and it never runs again. The bug hit version 2.22.1 and was fixed later. Any scheduler whose state is a database row can land here.
The history table that eats the disk
Every run writes a row to cron.job_run_details when cron.log_run is on, the default, and nothing prunes it. AWS recommends a second job that deletes old rows. So you need a cron job to clean up after your cron jobs, and if that one stops, the table grows until it takes the disk.
What you can't see anymore
No exit code, no MAILTO, no journal
Host cron gives you an exit code, an optional MAILTO email, and a line in the journal. pg_cron gives you none of those at the OS level. The only record is a row in cron.job_run_details with a status of succeeded or failed and a return_message. Turn cron.log_run off and even that disappears, leaving errors in postgresql.log among every other database message.
The job that never fires leaves no trace
A failed run writes a row you can query. A run that never happens writes nothing. An inactive job, a wrong target database, an exhausted worker pool, a scheduler on an un-promoted standby: none produce a failed row, because the job never started. Absence of a row is the only signal, and few dashboards watch for it.
Monitoring pg_cron from inside the job
The dead man's switch still works, but the check-in has to come from the job itself, and Postgres cannot make an outbound HTTP call on its own. Keep the schedule in pg_cron and have the job write a heartbeat row that an external check reads and alerts on when it goes stale. Or run the command from a host-side wrapper that calls psql and pings on success, handing you the exit code back:
#!/bin/bash
set -euo pipefail
# Run the maintenance SQL; abort the whole script if it fails
psql "$DATABASE_URL" -v ON_ERROR_STOP=1 -c "CALL refresh_daily_rollups();"
# Only reached if psql exited 0
curl -fsS --retry 3 https://cronguard.app/api/ping/your-monitor-id
Either way, the monitor alerts on a missed check-in instead of waiting for a failed row a never-started job will never write.
A checklist before you trust it
- pg_cron is in shared_preload_libraries on EVERY node that can become primary
- max_worker_processes is comfortably above cron.max_running_jobs
- a purge job trims cron.job_run_details on a schedule
- cron.job.active is true and the database column points where you think
- a dead man's switch watches for the absence of a successful run
That first line bites after a 3 a.m. failover, invisible until the day you fail over.
Frequently asked questions about pg_cron reliability
Does pg_cron keep running my jobs after a failover? Only if the new primary is configured for it. pg_cron does not run on a hot standby and starts on promotion, but it only schedules anything if pg_cron is in that node's shared_preload_libraries. Build every replica with the same setting, or a promotion leaves you with nothing scheduled.
Why did my pg_cron job stop without any error? Because a job that never starts writes no row to cron.job_run_details. Common causes are an exhausted worker pool, an inactive job, the wrong target database, or a scheduler on an un-promoted standby. There is no failed status to find, so you have to monitor for the absence of a successful run.
Where does pg_cron record whether a job succeeded? In the cron.job_run_details table, one row per run, with a status of succeeded or failed and a return message. That logging is on by default via cron.log_run. Turn it off to save space and results go only to postgresql.log, among every other database message.
Will pg_cron run two copies of the same job if one is slow? No. pg_cron runs at most one instance of a given job, and a run triggered while the previous one is still going is queued until it finishes. A job that regularly overruns its interval builds a backlog, which can look like the schedule slowing down.
How do I stop cron.job_run_details from filling the disk? Schedule a second job that deletes old rows, for example anything older than seven days, as AWS recommends on RDS. Monitor that cleanup job too, because if it is the one that silently stops, the history table grows until it takes the disk with it.
Further reading
- Building a Reliable Database Backup Strategy with Cron
- Dead Man's Switch Monitoring: The Only Reliable Way to Watch Cron Jobs
- Why Cron Jobs Fail Silently (And How to Catch Them)
Conclusion: Moving the scheduler into the database trades a host you had to maintain for failure modes you cannot see. pg_cron stops on a standby, can inherit broken state across a failover, fails when the worker pool is empty, and records nothing when a job never starts. None of that shows up as an error, which is why the only reliable check is a dead man's switch that fires when a successful run does not arrive on time.
Sources: pg_cron README, Scheduling maintenance with the PostgreSQL pg_cron extension (Amazon RDS), TimescaleDB issue 9360, PlanetScale changelog: pg_cron and pg_partman_bgw.