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.
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-indexnowjob. 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
sentwith 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 100per 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_netis strict about headers; itshttp_postaccepts aContent-Typeof exactlyapplication/jsonfor 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
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.
Keep reading
Supabase Security Definer Functions: The Exposure You Might Miss
In Supabase, public-schema functions are callable over the API — and SECURITY DEFINER ones bypass row-level security. What our audit found in 117 such functions, the real damage paths, and the grants and rules that close them.
Timezone Bugs in Web Apps: The Ones We Actually Hit
Real timezone bugs from a production app — a due date showing the previous day, "today" starting at the server's midnight, server-rendered times in the wrong zone — why each happened, and the rules that prevent them.
How to Build an Internal Help Desk with Next.js and Supabase
The architecture behind a production internal request desk — Postgres row-level security for tenancy, SLA timers the database computes, and AI that helps but never blocks — on free tiers.