pg_cron schedules SQL inside Postgres; cross-service jobs stay outside

cron.schedule(name, expression, sql) runs SQL or net.http_post on the database clock; cron.job_run_details is the log. Cross-service pipelines stay outside.

Extensions & Cron

· Chapter

41

·

3

min read

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.job is readable by anyone with table access.
  • db diff will not capture the extension; a fresh branch needs the create extension line 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