The answer. pg_cron turns the database into its own scheduler: cron.schedule('name', '*/15 * * * *', $$ delete from … $$) registers a job by name (re-running the statement updates it), jobs run as the role that created them on the database's UTC clock, and every run lands in cron.job_run_details with status and output. With pg_net a job can also make HTTP calls, which is how a cron invokes an Edge Function. That makes it ideal for work that is about the data and nothing else: purging anonymous users, sweeping orphaned auth rows, refreshing a materialized view, trimming its own run log. Our pipeline goes the other way: a Worker cron with isolated legs calls the database over HTTPS, because those legs also talk to a CMS, an LLM provider and an email service, and a scheduler that dies with the database cannot tell you the database is down.
The pattern.
create extension if not exists pg_cron; -- lives in pg_catalog; write this in a migration
select cron.schedule('purge-anon-users', '0 3 * * *',
$$ delete from auth.users where is_anonymous is true and created_at < now() - interval '30 days' $$);
select cron.schedule('invoke-digest', '*/5 * * * *', $$
select net.http_post(url := 'https://<ref>.supabase.co/functions/v1/digest',
headers := '{"Content-Type":"application/json","Authorization":"Bearer <publishable key>"}'::jsonb, body := '{}'::jsonb) $$);
select cron.schedule('trim-cron-log', '0 4 * * *', $$ delete from cron.job_run_details where end_time < now() - interval '7 days' $$);
select jobname, status, return_message, start_time from cron.job_run_details order by start_time desc limit 20;
select cron.unschedule('invoke-digest');
Watch out.
- Sub-minute schedules (
'30 seconds') are supported, and a job left running after a demo keeps filling tables in the background; unschedule what you do not need. - A job's SQL runs with the creator's privileges; create jobs as a role that can only touch what the job needs, and never embed a secret in the job text, since
cron.jobis readable by anyone with table access. db diffwill not capture the extension; a fresh branch needs thecreate extensionline in a migration or the jobs never exist.
Related: cron-one-tick-many-isolated-legs · anonymous-sign-ins-mint-real-jwts-gate-them-in-rls · edge-function-per-new-row-through-a-webhook