Skip to content
← Guides

Your Supabase cron job didn't run, and nothing told you.

Supabase Cron runs SQL, database functions and Edge Function calls on a schedule from inside Postgres, using the pg_cron extension. When a run doesn't happen, or happens and fails, nothing lands in your inbox: you find out when the data is stale. Here are the usual causes, straight from Supabase's documentation, and how to make the next missed run reach you.

Short answer

Check cron.job_run_details for the job's recent runs, and check that the pg_cron scheduler process is alive in pg_stat_activity. If it isn't, Supabase says the worker has died, and a fast reboot from General Settings revives it. Then make the job prove it ran: give it a watch in Did It Run? and end the job with a request to the watch's ping URL. If the job doesn't run or errors out, no ping arrives and you get a missed alert at the scheduled time plus your grace window, from outside Supabase. Start free (3 watches, no credit card).

Check-ins are by ping URL today, with /start and /fail variants (/start marks a run as begun; it doesn't count as a finished run). Email check-ins are coming soon.

Why a Supabase cron job doesn't run

  • The pg_cron scheduler process died. pg_cron runs as the pg_cron scheduler process inside Postgres. Supabase's debugging guide says that if a query on pg_stat_activity returns no row for it, the worker has died, and you revive it with a fast reboot from General Settings (Supabase, pg_cron debugging guide). While it's down, no new row appears in cron.job_run_details, so there's no failed run to find. Supabase recommends upgrading if your Postgres version is below 15.6.1.122, to get pg_cron 1.6.4+, which it says has many bug fixes and auto-revive capabilities.
  • The job is inactive. A job can be switched off with the Active/Inactive toggle in the Dashboard or with cron.alter_job(..., active := false) (Supabase, Cron quickstart). An inactive job just doesn't run.
  • It ran at a different time than you think. pg_cron's cron.timezone setting defaults to GMT (pg_cron README), and Supabase's own Cron examples label their times GMT, for example '30 3 * * 6' is "Saturday at 3:30AM (GMT)" (Cron quickstart). A fixed GMT time also shifts by an hour against your local clock when daylight saving time starts or ends.
  • The project was paused. Supabase pauses Free Plan projects that don't get enough user database activity over a 7-day period, emailing the owner about a week before and again once it's paused. Projects on paid plans are never paused for inactivity (Supabase, Project Pausing). A paused project's database is offline, so nothing scheduled in it can run; you restore it with Resume project in the Dashboard.
  • Too many jobs at once, or a strained database. pg_cron supports up to 32 concurrent jobs, each using a database connection, and Supabase says excessive resource strain can slow down or disrupt jobs (pg_cron debugging guide). For best performance Supabase recommends no more than 8 jobs running concurrently, each running no more than 10 minutes (Supabase, Cron).
  • The last run was still going. pg_cron runs only one instance of each job at a time. If a run is triggered before the previous one finishes, it's queued and starts as soon as the first one completes (pg_cron README), so a slow run pushes the next one late. Long-running jobs may also show timeout errors.
  • The job ran, but the SQL failed. The error is recorded in cron.job_run_details with a status other than succeeded. Supabase's Cron docs don't describe an alert for a failed or skipped run.
  • The job "succeeded", but its HTTP call didn't. If the job calls an Edge Function or a webhook with net.http_post or net.http_get, pg_net is asynchronous: the call returns a request ID, and the request isn't started until the transaction commits. The response is stored separately in net._http_response, for 6 hours by default, and the default timeout is 2,000 ms (Supabase, pg_net). So the cron run only shows that the request was queued, not that the function did its work.

Step 1: read what Supabase recorded

Run these in the SQL Editor.

  1. Is the job there, and is it active?
sql
select jobid, jobname, schedule, active from cron.job;
  1. What happened on its recent runs? (Also available from the job's History button in Integrations > Cron.)
sql
select status, return_message, start_time, end_time
from cron.job_run_details
where jobid = (select jobid from cron.job where jobname = 'your-job-name')
order by start_time desc
limit 10;

If the newest row is days old, the job isn't being started at all: check the scheduler next.

  1. Is the scheduler process alive?
sql
select pid, backend_start, state
from pg_stat_activity
where application_name ilike 'pg_cron scheduler';

No row means the worker has died. Supabase's fix is a fast reboot from General Settings (pg_cron debugging guide).

  1. If the job makes HTTP calls, did they fail?
sql
select *
from net._http_response
where status_code >= 400 or error_msg is not null
order by created desc;

Look soon: responses are kept for 6 hours by default (pg_net).

  1. For more detail, Supabase's debugging guide has a Logs query that pulls ERROR, FATAL and PANIC events from the Postgres logs where the application name matches pg_cron.

One housekeeping note: cron.job_run_details is never cleaned up automatically, even after a job is unscheduled, so Supabase suggests a cleanup job that keeps only recent rows (Cron quickstart).

Step 2: add a heartbeat so a missed run reaches you

  1. Create a watch in Did It Run? with the same cron expression as the job, in the UTC timezone (pg_cron's GMT default), with a grace window that covers the job's normal run time.
  2. Wrap the job in a database function that checks in when the work is done. pg_net only sends the request when the transaction commits, so the success ping goes out only if the work committed too:
sql
create or replace function nightly_job_with_checkin()
returns void
language plpgsql
as $$
begin
  perform do_nightly_work();  -- your job
  perform net.http_get('https://diditrun.app/p/<your-token>');
exception when others then
  perform net.http_get(
    'https://diditrun.app/p/<your-token>/fail',
    params := jsonb_build_object('text', left(sqlerrm, 200))
  );
end;
$$;

select cron.schedule('nightly-job', '0 3 * * *', 'select nightly_job_with_checkin()');

When the work raises an error, PostgreSQL rolls back that block's database changes and runs the handler (PostgreSQL docs, Trapping Errors), which sends /fail with the error message. Because the error was handled, cron.job_run_details records that run as succeeded; your Did It Run? watch shows it as failed. If you'd rather keep the failed status in cron.job_run_details, drop the exception block: the error then aborts the transaction, the success ping is never sent, and the run shows up as missed.

  1. If the job calls an Edge Function, check in from the function itself, at the end of its work, because the cron run only queues the request. Store the ping URL as an Edge Function secret (supabase secrets set DIDITRUN_PING=<your watch's ping URL>) and read it with Deno.env.get (Supabase, Edge Function secrets):
ts
const ping = Deno.env.get("DIDITRUN_PING")!;
try {
  const count = await doTheWork();
  await fetch(`${ping}?items=${count}`);
} catch (e) {
  await fetch(`${ping}/fail`, { method: "POST", body: String(e).slice(0, 200) });
  throw e;
}

Now each case is covered:

  • The job didn't run (scheduler died, job inactive, project paused): no ping arrives, so the run is missed at the deadline.
  • The job ran and failed: the handler or the Edge Function calls /fail and you get a failed alert. The alert shows the first 200 characters of the text you send.
  • The job was cancelled by a timeout: a statement_timeout cancel ends the statement; pg_cron records the run as failed in cron.job_run_details with a return_message like canceling statement due to statement timeout. PL/pgSQL's others doesn't catch QUERY_CANCELED (PostgreSQL docs, Trapping Errors), so the transaction aborts, no ping is sent, and Did It Run? shows the run as missed.
  • The job ran, but on nothing: send a count (?items= with the number of rows or items) and set an output sanity check on the watch; see Scheduled job ran but output was empty.

If the job could hang, also see Cron job or AI agent hung with no error.

Honest limits

  • Did It Run? doesn't connect to your Supabase project or read cron.job_run_details. It only sees the check-ins your job sends.
  • A missed alert tells you the run didn't arrive, not why. Step 1 is how you find out which cause it was.
  • The check-in itself goes through pg_net (or fetch in an Edge Function). If that request can't get out, the run shows up as missed, which is the safe way round.

What you'll see

  • Missed: no check-in by the expected time plus grace.
  • Late: an optional early warning while the run is still inside its grace window.
  • Failed: the job called /fail.
  • Empty: the check-in arrived but failed the watch's sanity check.
  • Recovered: the first good run after a problem.

Free covers 3 watches with email alerts; Solo ($15/month) adds Slack, Discord and Telegram alerts and a daily roll-call digest across all your automations.

FAQ

Why is my Supabase cron job not running?

Check that the pg_cron scheduler process appears in pg_stat_activity; if it doesn't, Supabase says the worker has died and a fast reboot from General Settings revives it. Also check that the job is active in cron.job, that you read its schedule in GMT, and, on the Free Plan, that the project hasn't been paused for inactivity.

What time zone does Supabase Cron use?

pg_cron's cron.timezone setting defaults to GMT, and Supabase's Cron examples label their schedules in GMT. Convert your local time to GMT/UTC when you write the cron expression.

Where can I see Supabase cron job history and errors?

In the cron.job_run_details table, or the job's History button in the Dashboard. Supabase notes the table is never cleaned up automatically. For HTTP calls made with pg_net, check net._http_response, which keeps responses for 6 hours by default.

Why does my cron job show succeeded when my Edge Function didn't run?

pg_net requests are asynchronous: the job queues the request and gets back a request ID, and the response is stored separately in net._http_response. Check in from the Edge Function itself when its work finishes so you know it actually ran.

Does Supabase alert me when a cron job fails or doesn't run?

Supabase's Cron docs don't describe an alert for a failed or skipped run. An outside heartbeat watch that expects a check-in on schedule catches both, because a run that never happens sends nothing.

Can I check in by email instead of a URL?

Not yet. Email check-ins are coming soon. Today every check-in is an HTTP request to the watch's ping URL, with /start and /fail variants.

Know the next time it doesn't run

Add one ping URL to the end of your job. If the ping doesn't arrive on time, Did It Run? emails you.

Free for 3 watches. No credit card.

Start free: 3 watches