Scheduled Jobs in Supabase with pg_cron: A Practical Guide

How to run scheduled jobs inside Postgres on Supabase with pg_cron — idempotent schedules in migrations, catch-up logic, locking down job functions, outbound HTTP with pg_net, monitoring runs, and the free-tier pause.

AAAayush AdhikariSeptember 24, 2026 7 min read

Supabase pg_cron scheduled jobs run SQL on a cron schedule inside your Postgres database, with no separate worker or hosting. Schedule them from migrations by name, so re-running a migration updates the job instead of duplicating it; make each job catch up on everything overdue rather than assuming it ran a minute ago; keep job functions closed to API roles; use pg_net when a job must call out over HTTP; and watch cron.job_run_details. LetRelay runs eleven jobs this way — queue workers, purges, alerts and blog pings — on the free plan.

What pg_cron is

pg_cron is an open-source Postgres extension that schedules SQL commands with standard cron syntax (17 * * * * = minute 17 of every hour), plus intervals in seconds ('30 seconds'). It runs inside the database server, uses GMT by default, can run several jobs in parallel, and runs only one instance of any given job at a time — a new run triggered while the previous one is still going waits until it finishes. Supabase ships it as an extension (and a "Cron" module in the dashboard).

For an app whose logic already lives in Postgres — row-level security, triggers, SQL functions — it's the natural place for background work.

What we run with it

LetRelay's jobs, as an example of what fits:

Job Schedule Purpose
Webhook queue worker every minute Reconcile outbound deliveries, retry with backoff
Rate-limit purge hourly Delete hits older than a day
Auth-attempt purge hourly Delete old login-attempt records
Assistant log purge hourly Apply retention to assistant logs
API spend alerts hourly Check budgets and renewals
Billing expiry hourly Move lapsed plans back to Free
Blog IndexNow ping hourly Announce newly published or edited posts
Room chat purge daily Free-plan chat retention
Answer-cache purge daily Expire cached AI answers
Webhook log purge daily Retention for delivery logs
Storage watchdog daily Protect the free database size limit

Notice the minutes: 17, 23, 33, 41, 47. Spreading jobs across the hour avoids everything firing at :00 together.

Schedule from migrations, by name

Keep schedules in version-controlled migrations, not clicked into a dashboard:

do $$ begin
  perform cron.schedule('relay-blog-indexnow', '17 * * * *', 'select public.blog_indexnow_ping()');
exception when others then
  raise notice 'pg_cron unavailable: %', sqlerrm;
end $$;

Two details:

  • Name every job. Scheduling again with the same name updates that job rather than adding a second one — we applied the migration above twice while fixing a bug and still have exactly one relay-blog-indexnow job. Unnamed jobs are hard to find and easy to duplicate.
  • Wrap it so a database without pg_cron (a local test instance) still applies the rest of the migration, with a notice explaining what didn't happen.

Put the actual work in a SQL function (public.blog_indexnow_ping()) and schedule a one-line call. Functions can be tested directly; long SQL strings inside cron.schedule can't.

Design jobs to catch up

The most important design rule: a job must not assume it ran recently. Jobs miss runs — the database restarted, a deploy, or on Supabase's free plan, the project paused after a week of inactivity. So:

  • Select by state, not by time window. "Deliveries still marked sent with no result" rather than "deliveries from the last minute".
  • Record what's done. LetRelay's blog ping keeps a small table of which version of each post has been announced, and announces anything newer — whether the last run was an hour or a week ago.
  • Process in bounded batches. limit 100 per run, so a long backlog drains over several runs instead of one enormous transaction.
  • Be idempotent. Running a job twice must not double-send or double-charge.

Outbound HTTP with pg_net

Some jobs must call the outside world: webhooks, search-engine pings, alerting. Supabase's pg_net extension makes asynchronous HTTP requests from SQL: net.http_post queues a request and returns an id; the response lands in net._http_response later. That shapes the design:

  • The job queues requests; a later run reconciles the responses and retries failures with backoff.
  • Responses are kept only for a limited time (six hours by default), so a reconcile job that doesn't run for longer — say, over a free-tier pause — must treat very old unconfirmed sends as unknown and retry them.
  • pg_net is strict about headers; its http_post accepts a Content-Type of exactly application/json for JSON bodies. We found that the hard way, with a test inside a rolled-back transaction.

The webhook system built on this is described in Slack and Discord to tickets with webhooks.

Lock down job functions

A function written for a cron job is still a function in your schema, and in Supabase that means it may be callable through the Data API. A purge function callable by anyone is a denial-of-service button; a queue worker callable by anyone processes other tenants' queues. Revoke execute from API roles:

revoke all on function public.blog_indexnow_ping() from public, anon, authenticated;

Jobs still run, because pg_cron executes as the job's owner. Why this matters is covered in Supabase security definer functions.

Test jobs without side effects

Testing a job that sends HTTP requests or deletes data is awkward. A trick that works well: run the function inside a block that always rolls back, and raise the results as an exception message:

do $$ declare n int; begin
  update blog_settings set public_site_url = 'https://example.test';
  n := blog_indexnow_ping();
  raise exception 'ROLLBACK sent=%', n;   -- everything above is undone
end $$;

The queued HTTP request, the state changes and the setting all roll back; you see the result in the error. We used this to prove the blog ping announces every live post once, nothing on a second run, and exactly one post after an edit — without sending a single real request.

Monitor runs

pg_cron records runs in cron.job_run_details: job id, status, start and end time, and the return message. Useful queries:

  • Failed runs in the last day, by job.
  • Jobs whose last successful run is older than expected.
  • Run duration trends — a purge that used to take 50 ms and now takes 5 s has a growing table or a missing index.

job_run_details isn't cleaned automatically, so add a job that trims it, like every other log table. On a free-tier database, unbounded logs are how you reach the size limit.

When not to use pg_cron

  • Long-running work (minutes of CPU, large file processing) — it competes with your app's queries.
  • Work that needs application code or secrets — AI calls, third-party SDKs. Use a scheduled route in your app instead, protected by a secret.
  • High-frequency, high-volume queues — a dedicated queue system is built for that.

LetRelay's AI reprocessing, for example, runs as a scheduled app route (it needs AI keys that never enter the database), while everything that's pure SQL or a simple HTTP ping runs in pg_cron. The overall free-tier design is in SaaS architecture on free tiers.

FAQ

How do I schedule a job in Supabase?

Enable the pg_cron extension and call cron.schedule('job-name', '<cron expression>', '<SQL>'), ideally from a migration, with the work inside a SQL function.

What timezone does pg_cron use?

GMT (UTC) by default, configurable with the cron.timezone setting. Write schedules with that in mind.

Do pg_cron jobs run while a Supabase free project is paused?

No. Paused projects don't run jobs, so design each job to catch up on everything overdue when it runs next.

Can pg_cron call external APIs?

With pg_net, which queues asynchronous HTTP requests from SQL. Reconcile responses in a later run and retry failures.

Sources

AA
Aayush Adhikari

Building Relay — the internal request desk with AI triage and SLA tracking.

Run your internal requests on LetRelay

AI triage, SLA-tracked queues, and bottleneck analytics — the help desk your team actually likes. Free to start.

Try LetRelay free No credit card required
Ad spaceYour Google AdSense unit shows here once approved.

Keep reading