Scheduler0 vs pg_cron: Choosing the Right Scheduler for Your Workload
"Cron" is not really a single tool, it is a category. The moment your product needs more than a crontab -e on a single VM, you start running into the same set of trade-offs: where should jobs run, who owns retries, what happens during failover, and how do non-engineers schedule things? Two of the most common answers in that space are pg_cron and Scheduler0, and they look superficially similar (both speak cron syntax, both run things on a schedule) but they are designed for different jobs.
This post is not a hit piece on pg_cron. pg_cron is excellent at what it does. The goal here is to give you a small framework you can apply to either tool, walk through how each one scores, and then help you decide which workloads belong where, including how to mix them.
A small framework for picking a scheduler
Before we line the two up, here are the eight axes we care about when evaluating any scheduler. If you only remember one thing from this post, remember the framework, not the verdict:
- Execution target — where does the job actually run, and what kinds of work can it do?
- Distribution and HA model — what happens when a node, region, or primary fails?
- Multi-cloud and multi-region — can the scheduler reach across providers?
- Retry semantics and idempotency — what happens on failure, and how do you avoid double-running?
- Observability — can you see what ran, what failed, and how the system is trending?
- Schedule expressiveness — cron precision, intervals, timezones, calendar features.
- Authoring ergonomics — APIs, dashboards, natural language, who can author a job?
- Operational footprint — who runs the scheduler itself, and how does it scale?
The rest of the post walks each axis, then collapses into "use pg_cron for X, Scheduler0 for Y" and a migration sketch.
How each tool scores on the framework
Rather than a giant table, here is the head-to-head per axis.
Execution target. pg_cron runs SQL or PL/pgSQL inside the same PostgreSQL instance it lives in. That is its whole superpower: zero network hops, full transaction context, and the job has the same identity as your database session. Scheduler0 takes the opposite philosophy. A job is a small declarative spec, and the executor is a separate concept: a webhook URL, an AWS Lambda, an Azure Function, or a Google Cloud Function. The job payload is delivered to your code, and your code does whatever it wants — including, yes, talking back to Postgres.
Distribution and HA. pg_cron is a PostgreSQL background worker. Its availability is your Postgres availability. If your primary fails over, your jobs typically follow the primary, and depending on your replication topology a small number of scheduled runs can be missed during the cutover. Scheduler0 is a Go service built on Raft consensus over an embedded SQLite store. As soon as you run more than one node, the cluster has a leader-elected coordinator that load-balances execution across peers, and surviving nodes continue executing jobs through a leader change. On restart, queued and overdue jobs are recovered: a job scheduled for "every 3 days" that misses its window because the cluster was down still fires when the cluster comes back online, as long as it returns before the next scheduled time.
Multi-cloud and multi-region. pg_cron lives wherever your Postgres lives. If you are on RDS in us-east-1, your cron lives in us-east-1. Scheduler0 is infrastructure-agnostic: a webhook executor can hit anything reachable over HTTPS, and the cloud-function executors target AWS, Azure, and GCP natively. You can fan one job out to a Lambda in one region and an Azure Function in another from the same scheduler.
Retries and idempotency. pg_cron does not retry by default; if your statement fails, it logs and moves on (you can build retry logic in PL/pgSQL, but it is on you). Scheduler0 has retries as a first-class field — retryMax per job — and, critically, it ships an idempotency model. Every execution is fingerprinted with a SHA-256 unique id derived from the job, project, last execution timestamp, and next scheduled time:
uniqueId = SHA256(projectId + "-" + jobId + "-" + lastExecutionDate + "-" + nextExecutionTime)
That id is stored alongside the execution state in committed and uncommitted log tables, which is how the system survives crashes without re-firing a job that already ran. pg_cron has no equivalent — duplicate execution after a crash is something you have to engineer around in your own SQL.
Observability. pg_cron writes to cron.job_run_details, which gives you a row per run with status and a return message. That is enough to debug a single job. Scheduler0 publishes execution logs with state, node, version, and retry counters; an analytics endpoint that buckets executions by minute for a date range; a totals endpoint for scheduled/success/failed counts; and a dashboard that visualizes all of this without you writing a query. If you need to expose "how is your scheduler doing?" to non-DBAs, the second model wins.
Schedule expressiveness. pg_cron uses standard 5-field cron (minute, hour, day, month, day-of-week), and recent versions added second-precision. Scheduler0 uses 6-field cron with seconds, supports the standard @yearly, @monthly, @weekly, @daily, @hourly shortcuts, and adds Go-style interval syntax like @every 1h30m10s or @every 5m. Both support timezones, but Scheduler0 stores timezone and offset on the job itself so a single deployment can host jobs in many regions without any session-level gymnastics.
Authoring ergonomics. pg_cron is authored in SQL. That is great for DBAs and miserable for the support engineer who just wants to schedule a follow-up email. Scheduler0 has a REST API, official Go/Node/Python clients, a CLI, and an AI prompt endpoint that turns "Follow up 2 days after the demo" into a structured job spec. That last one matters more than it sounds: it lets you put scheduling directly in front of end users without teaching them cron.
Operational footprint. pg_cron is a Postgres extension — no new service, no new infrastructure, no new on-call. Scheduler0 is either a managed service (no infrastructure for you) or a self-hosted Raft cluster that you operate alongside your other services. If you are unwilling to add either a managed dependency or a small Go cluster to your stack, that is a real cost that pg_cron does not impose.
Architecture, side by side
The simplest way to feel the difference is to draw the two systems next to each other. pg_cron lives entirely inside Postgres; Scheduler0 is a small distributed control plane in front of pluggable executors.
pg_cron Scheduler0
------- ----------
+----------------------------+ +-------------------------------+
| PostgreSQL | | Raft cluster (>=1 node) |
| | | leader-elected coordinator |
| +--------------------+ | | embedded SQLite per node |
| | pg_cron bg worker | | +---------------+---------------+
| | reads cron.job | | |
| +---------+----------+ | schedule + dispatch (HTTPS)
| | | |
| v | v
| +--------------------+ | +-------------------------------+
| | SQL / PL/pgSQL | | | Executors |
| | (in-process) | | | - webhook URL |
| +---------+----------+ | | - AWS Lambda |
| | | | - Azure Function |
| v | | - GCP Function |
| +--------------------+ | +---------------+---------------+
| | cron.job_run_ | | |
| | details | | v
| +--------------------+ | +-------------------------------+
+----------------------------+ | your application code |
| (may then call Postgres, |
| S3, Slack, ...) |
+---------------+---------------+
|
v
+-------------------------------+
| execution log + retry + |
| SHA-256 idempotency key |
+-------------------------------+
The pg_cron column has one hop and one home; the Scheduler0 column has more boxes, but each box is a place where you get behavior (HA, retries, fan-out, observability) you would otherwise build yourself.
The same job, both ways
To make this concrete, take a common workload: refresh a sales rollup every night at 2 AM, then notify the team. Here is the lifecycle of a single execution in each system:
pg_cron flow Scheduler0 flow
------------ ---------------
cron.schedule(cron, SQL) POST /v1/jobs { spec, executorId }
| |
v v
pg_cron bg worker fires Raft leader queues next run
| |
v v
runs SQL inside Postgres HTTPS call to executor
| |
v v
row written to your code runs the work
cron.job_run_details |
v
success -> commit log entry
failure -> retry up to retryMax
(idempotent via uniqueId)
In pg_cron the whole thing lives inside Postgres:
SELECT cron.schedule(
'nightly-rollup',
'0 2 * * *',
$$REFRESH MATERIALIZED VIEW CONCURRENTLY sales_rollup;$$
);
SELECT cron.schedule(
'nightly-rollup-notify',
'5 2 * * *',
$$INSERT INTO outbox(channel, payload)
VALUES ('email', json_build_object('subject','Sales rollup ready'));$$
);
That is genuinely tidy. The work happens where the data lives, and there is no network in the loop.
In Scheduler0, the schedule and the work are separated. You define an executor once (a webhook URL or a cloud function) and then create the job:
curl -X POST "https://api.scheduler0.com/v1/jobs" \
-H "Content-Type: application/json" \
-H "X-API-Key: $KEY" -H "X-Secret-Key: $SECRET" -H "X-Account-ID: $ACCT" \
-d '[{
"projectId": 456,
"executorId": 789,
"spec": "0 0 2 * * *",
"data": "{\"task\":\"refresh_sales_rollup\"}",
"retryMax": 3,
"timezone": "America/New_York",
"createdBy": "ops"
}]'
A few things change. The cron field has six positions — leading second — so 0 0 2 * * * reads as "second 0, minute 0, hour 2, every day". The retryMax: 3 means a transient failure (network blip to your refresh endpoint, lock contention) gets three automatic retries before the job is marked failed. The timezone is per-job, so a tenant in New York and a tenant in Berlin can each run "at 2 AM local" without you maintaining two Postgres clusters.
If your end users author schedules, the prompt endpoint lets them skip the cron field entirely:
curl -X POST "https://api.scheduler0.com/v1/prompt" \
-H "Content-Type: application/json" \
-H "X-API-Key: $KEY" -H "X-Secret-Key: $SECRET" -H "X-Account-ID: $ACCT" \
-d '{
"prompt": "Refresh the sales rollup every night at 2 AM and notify the team",
"timezone": "America/New_York"
}'
The response is a structured job spec with a generated cron expression you can hand straight to the jobs API.
When pg_cron is the right answer
Reach for pg_cron when the work is the database. Examples that are almost always better in pg_cron:
- Materialized view refreshes for a single primary.
- Partition rotation and retention (drop partitions older than N days).
VACUUM,ANALYZE, and other maintenance you do not want flowing through a network.- Archival moves between tables in the same instance.
- Single-Postgres shops where adding a new service is not justified.
Co-locating the schedule with the data it touches removes a class of failure modes (network, auth, payload size). If your scheduler's only customer is your database, pg_cron is genuinely the right tool.
When Scheduler0 is the right answer
Reach for Scheduler0 when the work is application-level, crosses systems, or has consumers that are not DBAs. Concretely:
- App-level scheduling that fans out to webhooks, Lambdas, Azure Functions, or GCP Functions.
- Anything that needs first-class retries and idempotency without you re-implementing them.
- User-facing scheduling features (reminders, follow-ups, digests) — especially if you want to expose a natural-language interface via the prompt API.
- Multi-tenant SaaS where every tenant needs its own jobs, its own analytics, and its own API keys.
- HA across nodes that does not depend on database failover behavior.
- Anything that needs to survive a primary database failover without skipping a beat — Scheduler0's job state lives in its own Raft cluster, not in your Postgres.
- Per-tenant observability surfaced to humans (analytics buckets, totals, dashboards) instead of a
cron.job_run_detailstable you have to write SQL against.
Migrating, or running both
The good news: this is rarely an "either/or" decision. The pattern we see most often is:
- Keep DB-local maintenance in pg_cron. It is one line of SQL and it never leaves the database.
- Move app-level scheduling — anything user-facing, multi-cloud, or retry-sensitive — to Scheduler0.
- For workloads that need both (e.g. "refresh a view, then notify"), let Scheduler0 own the schedule and call a small endpoint in your app that runs the SQL via your normal DB pool. You keep auditability, retries, and idempotency, and your DBAs still own the SQL.
A few practical notes if you do migrate:
- Cron syntax differs. pg_cron is 5-field (minute first); Scheduler0 is 6-field (second first).
0 2 * * *in pg_cron becomes0 0 2 * * *in Scheduler0. Mind the leading zero. - Use
@everyfor intervals. Many "run this every N minutes" jobs read better as@every 5mthan as a cron expression. - Set
timezoneon the job, not in the database session. Scheduler0 stores it per-job, which makes per-tenant scheduling straightforward. - Pick
retryMaxthoughtfully. Default is conservative; the upgraded tier supports up to 15 retries. Idempotent endpoints can take more retries safely. - Embrace executors. A webhook URL is the simplest executor and is usually the right starting point. Move to a cloud function executor when you want the scheduler to invoke serverless directly.
Closing
The framework — execution target, HA, multi-cloud, retries and idempotency, observability, schedule expressiveness, authoring ergonomics, operational footprint — is the part that should outlast this post. pg_cron wins decisively when the work is the database and the audience is the DBA. Scheduler0 wins decisively when the work is the application, the audience includes humans who do not want to learn cron, and you need retries, idempotency, multi-cloud reach, and analytics out of the box.
If you want to go deeper, the Scheduler0 documentation covers jobs, executors, the AI prompt endpoint, and self-hosting, and the API reference has the full surface area. Pick the tool that matches the workload, and feel free to use both.
