Skip to content
Discord Get Started

pg_cron — Scheduled Tasks

pg_cron runs SQL statements on a recurring schedule. Jobs are persisted in TiKV, executed by a background worker, and tracked with full execution history.

pg_cron is a default extension — it is pre-installed when a database is created. You can verify with:

SQL
SELECT * FROM pg_extension WHERE extname = 'pg_cron';

If needed, install manually:

SQL
CREATE EXTENSION IF NOT EXISTS pg_cron;

The 3-argument form assigns a name to the job. If a job with the same name already exists for the current user, it updates the existing job instead of creating a duplicate (upsert).

SQL
SELECT cron.schedule('cleanup', '0 3 * * *',
$$DELETE FROM logs WHERE created_at < now() - interval '7 days'$$);
-- Returns: job_id (e.g., 1)
-- Call again with the same name to update schedule or command
SELECT cron.schedule('cleanup', '0 4 * * *',
$$DELETE FROM logs WHERE created_at < now() - interval '30 days'$$);
-- Returns: same job_id (1)

Creates an anonymous job (no name, no upsert). Returns the assigned job ID.

SQL
SELECT cron.schedule('*/5 * * * *', 'SELECT check_alerts()');

pg_cron uses standard 5-field cron expressions. Six-field expressions (with seconds) and special strings (@daily, @hourly) are not supported.

Output
┌───────────── minute (0–59)
│ ┌───────────── hour (0–23)
│ │ ┌───────────── day of month (1–31)
│ │ │ ┌───────────── month (1–12)
│ │ │ │ ┌───────────── day of week (0–6, Sunday = 0)
│ │ │ │ │
* * * * *

Supported operators: * (any), , (list), - (range), / (step).

ExpressionRuns
* * * * *Every minute
*/5 * * * *Every 5 minutes
0 * * * *Every hour
0 0 * * *Daily at midnight
0 3 * * 1-5Weekdays at 3 AM
0,30 9-17 * * *Every 30 min during business hours
0 0 1 * *First day of every month
0 0 * * 0Every Sunday at midnight
SQL
SELECT jobid, jobname, schedule, command, active
FROM cron.job
ORDER BY jobid;

cron.alter_job updates individual fields. Pass NULL for fields you do not want to change.

SQL
-- Change schedule only
SELECT cron.alter_job(1, '0 4 * * *', NULL, NULL, NULL, NULL, NULL);
-- Disable a job
SELECT cron.alter_job(1, NULL, NULL, NULL, NULL, false, NULL);
-- Set max runtime to 30 minutes
SELECT cron.alter_job(1, NULL, NULL, NULL, NULL, NULL, '30min');

Signature: cron.alter_job(job_id, schedule, command, database, username, active, max_runtime)

All arguments after job_id accept NULL to leave unchanged. The database argument must always be NULL (cross-database scheduling is not supported). Only superusers can change the username (job owner).

max_runtime formats: '30min', '2h', '60s', '5000ms', or an integer (milliseconds). Use '0' for no limit.

SQL
-- By name
SELECT cron.unschedule('cleanup');
-- By ID
SELECT cron.unschedule(1);

Returns true if the job existed and was deleted, false if it did not exist. Deleting a job also removes its execution history.

SQL
SELECT cron.cancel(5);

Returns true if a running execution was found and a cancel signal was sent. Superuser only.

SQL
SELECT jobid, runid, status, return_message, start_time, end_time
FROM cron.job_run_details
WHERE jobid = 1
ORDER BY runid DESC
LIMIT 20;
StatusMeaning
startingJob is about to execute
runningExecution in progress
succeededCompleted successfully
failedSQL error, timeout, or orphan recovery
cancelledCancelled via cron.cancel()
SQL
SELECT run_id, job_id, username, command, elapsed_ms
FROM cron.running_jobs;

Superuser only — non-superusers see an empty result set.

Run records are automatically cleaned up after 7 days (configurable via DB9_CRON_RUN_RETENTION_DAYS).

ColumnTypeDescription
jobidBIGINTUnique job identifier
scheduleTEXT5-field cron expression
commandTEXTSQL command
nodenameTEXTAlways localhost
nodeportBIGINTAlways 5433
databaseTEXTDatabase name
usernameTEXTJob owner
activeBOOLEANWhether the job is enabled
jobnameTEXTJob name (NULL for anonymous jobs)
max_runtimeTEXTFormatted max execution time (e.g., 30min) or default
next_run_atTEXTNext scheduled execution (epoch format)

Non-superusers see only their own jobs.

ColumnTypeDescription
jobidBIGINTJob ID
runidBIGINTUnique run identifier
job_pidBIGINTProcess ID (NULL if not started)
databaseTEXTDatabase name
usernameTEXTJob owner
commandTEXTSQL command executed
statusTEXTstarting, running, succeeded, failed, or cancelled
return_messageTEXTOutput or error message
start_timeTEXTStart timestamp (epoch ms)
end_timeTEXTEnd timestamp (epoch ms)

Non-superusers see only runs from their own jobs.

ColumnTypeDescription
run_idBIGINTCurrent run identifier
job_idBIGINTJob ID
keyspaceTEXTTiKV keyspace
db_idBIGINTInternal database ID
usernameTEXTJob owner
commandTEXTSQL command being executed
started_atTEXTStart timestamp (epoch ms)
elapsed_msBIGINTMilliseconds since start

Superuser only.

All commands use db9 db cron <subcommand> <database>.

Terminal
db9 db cron list <database>
db9 db cron list <database> --json
Terminal
# Inline SQL
db9 db cron create <database> '0 3 * * *' --command 'VACUUM'
# Named job (enables upsert)
db9 db cron create <database> '*/15 * * * *' --name refresh --command 'REFRESH MATERIALIZED VIEW mv_stats'
# SQL from file
db9 db cron create <database> '0 0 * * *' --name daily-report --file report.sql
Terminal
db9 db cron delete <database> <job_id_or_name>
Terminal
db9 db cron history <database>
db9 db cron history <database> --job cleanup --limit 50
Terminal
db9 db cron enable <database> <job_id_or_name>
db9 db cron disable <database> <job_id_or_name>
Terminal
db9 db cron status <database>
db9 db cron status <database> <job_id_or_name>
  • Each job runs in its own temporary database connection with the job owner’s credentials
  • Commands auto-commit (no wrapping transaction)
  • Output and errors are captured in return_message
  • The worker polls for due jobs every 60 seconds
  • A claim guard prevents the same job from running twice in the same minute
TimeoutDefaultDescription
Job execution timeout30 minutesGlobal default; overridable per job via max_runtime
Orphan recovery5 minutesRunning jobs with no heartbeat are marked failed

If a job exceeds its timeout, it is terminated and marked failed with the message “orphan recovery: execution timed out”.

OperationNon-superuserSuperuser
Schedule jobsOwn jobsAny
View jobs (cron.job)Own jobs onlyAll
Alter jobsOwn jobs onlyAny; can also change owner
Unschedule jobsOwn jobs onlyAny
Cancel running jobsNoYes
View running jobsNo (empty result)Yes
View execution historyOwn runs onlyAll
SQL
SELECT cron.schedule('cleanup-logs', '0 3 * * 0', $$
DELETE FROM audit_logs
WHERE created_at < now() - interval '90 days'
$$);
SQL
SELECT cron.schedule('sync-rates', '0 * * * *', $$
INSERT INTO exchange_rates (fetched_at, data)
SELECT now(), (http_get('https://api.example.com/rates')).content::jsonb
$$);
SQL
SELECT cron.schedule('daily-metrics', '0 6 * * *', $$
SELECT extensions.fs9_write(
'/reports/metrics-' || to_char(now(), 'YYYY-MM-DD') || '.csv',
(SELECT string_agg(metric || ',' || value::text, E'\n')
FROM app_metrics WHERE ts > now() - interval '1 day')
)
$$);
SQL
SELECT cron.schedule('refresh-mv', '*/30 * * * *',
'REFRESH MATERIALIZED VIEW mv_dashboard_stats');
LimitValue
Max jobs per database50
Max concurrent jobs (global)32
Cron expression fields5 (no seconds)
Default job timeout30 minutes
Orphan recovery timeout5 minutes
Execution history retention7 days
Worker poll interval60 seconds

See Limits and Quotas for the complete list.

ErrorCause
extension pg_cron is not installedRun CREATE EXTENSION pg_cron first
invalid cron expression: 6-field expressions (with seconds) are not supportedUse 5-field syntax
invalid cron expression: special strings like '@daily' are not supportedUse 0 0 * * * instead
cron.schedule: {arg} must not be NULLRequired argument is NULL
cron.unschedule: must be superuser or owner of the jobPermission denied
cron.cancel: must be superuserOnly superusers can cancel
cron.alter_job: cross-database scheduling not supporteddatabase arg must be NULL