Supabase Cron and pg_cron not running: what to check, and how to get told
Supabase Cron runs on pg_cron, inside your database. When its scheduler process dies or a job fails, the evidence sits in tables nobody is watching. Check the three queries and the causes below, then end each job with a net.http_get to LastPing, so a run that does not happen opens an incident.
Your assistant can do this for you
Find out why my Supabase cron job is not running, and monitor it with LastPing
On this page
Check these first: three queries
The scheduler, its jobs and their history all live in the database, so start there.
-- Is the scheduler process alive?
select pid from pg_stat_activity where application_name ilike 'pg_cron scheduler';
-- Which jobs exist, and are they active?
select jobid, jobname, schedule, active from cron.job;
-- What happened on the last runs?
select jobid, status, return_message, start_time, end_time
from cron.job_run_details order by start_time desc limit 20;
cron.job_run_details "grows with every Job run and is never cleaned up automatically, even after a Job is unscheduled", so Supabase suggests a daily job that deletes rows older than 7 days.
Eight reasons a Supabase cron job does not run
In the order worth checking, quoting the Supabase and pg_cron documentation where it is exact.
The scheduler process has died
pg_cron runs as a process inside Postgres. If this query returns no row, Supabase's debugging guide says "the worker has died", and the fix is a fast reboot from the project's General Settings:
select pid, backend_start, state
from pg_stat_activity
where application_name ilike 'pg_cron scheduler';
Supabase recommends Postgres v15.6.1.122 or later, which brings pg_cron 1.6.4 with "auto-revive capabilities".
The job is inactive
Every job has an active flag, and cron.alter_job(<jobid>, active := false) switches a job off without deleting it. select jobid, jobname, schedule, active from cron.job; shows them all.
The schedule is in GMT
pg_cron's cron.timezone setting defaults to GMT, and Supabase's own examples are written in GMT: 30 3 * * 6 is Saturday at 03:30 GMT. Paste the expression into the cron expression explainer to see those runs in your own zone.
The job failed
Each run and its status is recorded in cron.job_run_details. Supabase's debugging query lists the runs that neither succeeded nor are still running; return_message holds the error. Long-running jobs may show timeout errors there: Supabase suggests wrapping such a query in a function with its own timeout.
select jobid, status, return_message, start_time
from cron.job_run_details
where status <> 'succeeded' and status <> 'running'
and start_time > now() - interval '5 days'
order by start_time desc
limit 10;
Too many jobs at once
pg_cron supports up to 32 concurrent jobs, each using a database connection (cron.max_running_jobs, default 32). Supabase goes further: "For best performance, we recommend no more than 8 Jobs run concurrently", and "Each Job should run no more than 10 minutes." Space jobs out rather than starting them all on the hour.
The previous run is still going
pg_cron runs only one instance of a 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." A job that takes longer than its interval falls further behind on every run.
A seconds schedule on an older Postgres
pg_cron accepts intervals such as '30 seconds' (1 to 59 seconds, not combined with other units). On Supabase, seconds need Postgres 15.1.1.61 or later.
The database is a standby
From the pg_cron documentation: it "does not run any jobs as long as a server is in hot standby mode, but it automatically starts when the server is promoted." Jobs belong on the primary.
Get alerted when a job does not run
The scheduler lives inside the database, so when it stops, nothing inside the database can say so. A ping from the job, checked against the schedule outside it, can.
Create a monitor with the job's schedule
In the console, create a monitor with the job's cron expression in UTC (GMT), plus a grace window. Copy the ping URL.
Ping from the job with pg_net
Supabase documents calling HTTP from a cron job with pg_net. Put the work and the ping in one function, ping last:
create or replace function nightly_cleanup()
returns void language plpgsql as $$
begin
delete from events where event_time < now() - interval '1 week';
perform net.http_get('https://ping.lastping.dev/<your-monitor-id>');
end;
$$;
select cron.schedule('nightly-cleanup', '0 3 * * *', 'select nightly_cleanup()');
pg_net's documentation is what makes this safe: "HTTP requests are not started until the transaction is committed." If the delete fails, the transaction rolls back and the ping is never sent, so a failed run looks like a missed one and opens an incident after the grace window.
Optional: report the error at once
To be alerted immediately with the error text, catch it and post it to /fail. The catch means the run is recorded as succeeded in cron.job_run_details, so LastPing becomes the place the failure shows:
create or replace function nightly_cleanup()
returns void language plpgsql as $$
begin
begin
delete from events where event_time < now() - interval '1 week';
exception when others then
perform net.http_post(
url := 'https://ping.lastping.dev/<your-monitor-id>/fail',
body := jsonb_build_object('error', SQLERRM)
);
return;
end;
perform net.http_get('https://ping.lastping.dev/<your-monitor-id>');
end;
$$;
pg_net keeps each response for 6 hours in net._http_response, so select status_code, error_msg, created from net._http_response order by created desc limit 5; shows whether the pings got through. Its requests time out after 2,000 ms by default.
What this catches
The job reports what it can; the missing ping reports the rest.
- The scheduler stopped: a dead pg_cron process, a database that is down or a standby that was never promoted. No ping in the window opens an incident.
- The job was switched off: an inactive job, or one removed by a migration, sends nothing, and the silence raises the alert.
- The job failed: the rolled-back transaction never sends its ping, so the incident opens after the grace window, or at once with the optional
/failvariant. - The job fell behind: a run queued behind a slow previous run pings late; past the grace window, that is an incident too.
- Recovery: the next successful run closes the incident and sends a recovery notice.
The same pattern works for pg_cron outside Supabase: the pg_cron documentation covers loading it through shared_preload_libraries and choosing its database with cron.database_name.
Questions people ask
Front-loaded answers: the most important fact first.
-
Why is my pg_cron job not running?
Check that the pg_cron scheduler process is alive in pg_stat_activity, that the job is active in cron.job, and what cron.job_run_details recorded for its last runs. Remember the schedule is in GMT by default, that a job never runs twice at once, and that pg_cron runs no jobs on a hot standby.
-
Where can I see Supabase cron job history?
In the cron.job_run_details table: each run with its status, return_message, start_time and end_time. It is never cleaned up automatically, so schedule a job that deletes old rows.
-
What timezone does Supabase Cron use?
GMT. pg_cron's cron.timezone setting defaults to GMT, and Supabase's examples are written in GMT.
-
How many cron jobs can run at the same time?
pg_cron supports up to 32 concurrent jobs, each using a database connection. Supabase recommends no more than 8 running concurrently, and no job running longer than 10 minutes.
-
Is a pg_net request sent if the cron job fails?
No. pg_net does not start HTTP requests until the transaction is committed, so a job that fails and rolls back never sends its ping. That is why a ping at the end of the job is a reliable success signal.
-
How do I get alerted when a Supabase cron job does not run?
Create a LastPing monitor with the job's schedule in GMT and a grace window, and end the job's function with perform net.http_get to the monitor URL. A missed or failed run sends no ping and opens an incident.
-
Is this monitoring free?
Yes. LastPing is free for individuals with no monitor cap. See how it compares to Healthchecks.io and Cronitor.
The scheduler inside your database can't report its own death.
One ping at the end of the job now beats finding a week of stale rows later. First alert wired up in minutes, and LastPing monitors itself the same way.