PostgreSQL Scheduling and pg_cron

This note is the PostgreSQL equivalent of the SQL Server Agent jobs note, but the first operational fact is that PostgreSQL core does not ship a built-in Agent-like scheduler. The closest in-database analogue is pg_cron, an extension that runs scheduled SQL inside PostgreSQL with one metadata database, optional cross-database targeting, and catalog tables that expose job definitions plus run history. The design question is therefore two-step: decide whether the workload belongs inside the database at all, then decide whether pg_cron is the right owner or whether the work should stay in cron, systemd timers, Airflow, Dagster, or another orchestrator.

Current Scheduler State

Before discussing schedules, first prove whether the cluster currently exposes any in-database scheduling surface at all. On PostgreSQL that means checking for extension files and startup-loaded background-worker libraries, not checking a single built-in service switch.

PostgreSQL | scheduler surface | verify whether the cluster currently exposes pg_cron

This subsection answers the same opening question that the SQL Server Agent note answers for Agent XPs: is a scheduler surface actually present on the live instance, or is the idea still hypothetical?

Check whether pg_cron is currently available as an extension

Run this at the start of any PostgreSQL scheduling review, before editing postgresql.conf, installing packages, or assuming a scheduler already exists. It is typically triggered by first host audit, post-rebuild validation, or a request to “just add a scheduled job” on a cluster whose extension surface is still unknown. The query runs in a SQL session, is read-only, and requires only visibility into pg_available_extensions. Its purpose is to prove whether PostgreSQL already has the pg_cron extension files available to install.

Output fieldSource columnUnit / typeMeaning
namepg_available_extensions.namenameThe extension name known to the current PostgreSQL installation.
default_versionpg_available_extensions.default_versiontextThe default version PostgreSQL would install if CREATE EXTENSION were executed.
installed_versionpg_available_extensions.installed_versiontextThe version currently installed in the connected database, if any.

This query checks whether the current PostgreSQL installation exposes pg_cron at all.

SELECT
    name,
    default_version,
    installed_version
FROM pg_available_extensions
WHERE name = 'pg_cron';
 name | default_version | installed_version
------+-----------------+-------------------
(0 rows)

The live lab currently has no pg_cron extension files installed, so there is no in-database scheduler surface to enable yet. This is the key PostgreSQL difference from SQL Server Agent: the scheduler is not dormant inside core waiting for a switch. It must exist as packaged extension code first.

Check whether any startup preload libraries are active

Run this immediately after the extension-availability check when the next question is whether the postmaster is already loading any background-worker libraries at startup. It is typically triggered by scheduler enablement planning, extension troubleshooting, or a need to prove that the instance is still close to package defaults. The query runs in a SQL session, is read-only, and needs only current_setting. Its purpose is to determine whether a restart-scoped worker library is already loaded into the server.

Output fieldSource expressionUnit / typeMeaning
shared_preload_librariescurrent_setting('shared_preload_libraries')textThe comma-separated list of libraries PostgreSQL is loading at postmaster start.

This query returns the current startup preload list.

SELECT current_setting('shared_preload_libraries') AS shared_preload_libraries;
 shared_preload_libraries
--------------------------
 
(1 row)

The preload list is empty, which means there is no currently loaded scheduler worker and no restart-scoped extension surface in play. That is the correct baseline for a lab that has not yet been explicitly prepared for in-database scheduling.

Enablement on Linux

Enabling pg_cron on Linux is a sequence, not one command. PostgreSQL first needs the extension package on disk, then the postmaster must load the shared library at server start, then the metadata functions and tables must be created in exactly one database.

PostgreSQL / Linux | pg_cron | install and enable the extension

This subsection covers the complete enablement path from “the package is missing” to “the cluster exposes cron.* settings and the extension can be created.” The boundary that matters most is restart scope: shared_preload_libraries is a postmaster setting, so installation alone never makes the scheduler active.

SettingWhat it controlsDefault in the live lab after worker loadPossible valuesProduction guidance
shared_preload_librariesStartup-loaded shared libraries that may allocate shared memory or start background workers.pg_cron during the enablement captureComma-separated library names, startup-only.Keep it minimal because a bad library name can prevent PostgreSQL from starting.
cron.database_nameThe one database that stores pg_cron metadata.postgresAny database name in the cluster, startup-only.Choose deliberately; all pg_cron metadata lives here, even when jobs target other databases.
cron.hostThe hostname or socket path used when pg_cron opens local libpq connections.localhostHostname, socket directory, or empty string, startup-only.Align it with the intended local-authentication model in pg_hba.conf.
cron.use_background_workersWhether pg_cron launches jobs via background workers instead of local libpq connections.offon or off, startup-only.Turn it on only when the concurrency and worker-process budget are designed for it.
max_worker_processesCluster-wide budget for background workers.8 before any tuningInteger, startup-only.Raise it only if background-worker scheduling or other extensions genuinely need more capacity.
cron.timezoneTime zone used for pg_cron schedule interpretation.GMTAny PostgreSQL-supported time zone, startup-only.Set it explicitly if the estate does not want GMT semantics.

Install the pg_cron package for PostgreSQL 16

Run this when the extension-availability query has confirmed that pg_cron is absent and the next operational step is to make the extension files available to the server. It is typically triggered by first-time scheduler enablement on a self-managed Linux host or container. The command runs in the Linux shell with root-level package-management privileges, is state-changing, and affects the container or host filesystem rather than the database catalogs directly. Its purpose is to install the shared library and SQL extension files PostgreSQL needs before any database-level enablement can happen.

This command installs pg_cron from the configured PGDG apt repository inside the live PostgreSQL container.

docker exec stoxx-postgres bash -lc "DEBIAN_FRONTEND=noninteractive apt-get install -y postgresql-16-cron"
Reading package lists...
Building dependency tree...
Reading state information...
The following NEW packages will be installed:
  postgresql-16-cron
0 upgraded, 1 newly installed, 0 to remove and 3 not upgraded.
Need to get 91.8 kB of archives.
After this operation, 233 kB of additional disk space will be used.
Get:1 http://apt.postgresql.org/pub/repos/apt trixie-pgdg/main amd64 postgresql-16-cron amd64 1.6.7-2.pgdg13+1 [91.8 kB]
Setting up postgresql-16-cron (1.6.7-2.pgdg13+1) ...

The package install places the pg_cron shared library under PostgreSQL’s library path and the extension SQL files under /usr/share/postgresql/16/extension. That still does not make the scheduler active. PostgreSQL must load the library at server start before any cron.* settings or background worker can appear.

Stage pg_cron for server-start loading

Run this after the package is installed and before the restart window begins. It is typically triggered by planned scheduler enablement after confirming that a restart is acceptable. The command runs in a SQL session as a superuser, is state-changing, and writes PostgreSQL configuration metadata rather than immediately changing the running server. Its purpose is to tell the postmaster to load pg_cron on the next start.

This command stages pg_cron into the startup preload list.

ALTER SYSTEM SET shared_preload_libraries = 'pg_cron';
ALTER SYSTEM

This is only the configuration step. PostgreSQL documentation is explicit that shared_preload_libraries is server-start only because some modules need shared memory, lightweight locks, or background workers during postmaster initialization. Until the restart happens, pg_cron is still inert.

Restart PostgreSQL so the pg_cron worker can register its settings

Run this only after the restart-scoped configuration has been staged and the operational window permits a database restart. It is typically triggered by extension enablement or extension removal, not by day-to-day job administration. The command runs in Docker on the host, is state-changing, and interrupts active sessions while the container restarts. Its purpose is to let the postmaster load the pg_cron library and register the cron.* configuration surface.

This command restarts the PostgreSQL container after the preload change.

docker restart stoxx-postgres
stoxx-postgres

Inspect the pg_cron runtime settings after restart

Run this immediately after the restart to verify that the worker actually loaded and to see which execution model the cluster is now using. It is typically triggered by post-restart validation or troubleshooting when CREATE EXTENSION or job execution does not behave as expected. The query runs in a SQL session, is read-only, and needs only access to pg_settings. Its purpose is to prove that the pg_cron library is loaded and to surface the settings that control where metadata lives, how connections are made, and how much concurrency is possible.

Output fieldSource columnUnit / typeMeaning
namepg_settings.nametextThe setting name.
settingpg_settings.settingtextThe current effective value.
contextpg_settings.contexttextWhether the setting is startup-only, reloadable, or otherwise scoped.
sourcepg_settings.sourcetextWhere the current value came from.

This query inspects the most important pg_cron runtime settings after the worker is loaded.

SELECT
    name,
    setting,
    context,
    source
FROM pg_settings
WHERE name IN
(
    'shared_preload_libraries',
    'cron.database_name',
    'cron.host',
    'cron.use_background_workers',
    'cron.max_running_jobs',
    'cron.timezone'
)
ORDER BY name;
            name             |  setting  |  context   |       source
-----------------------------+-----------+------------+--------------------
 cron.database_name          | postgres  | postmaster | default
 cron.host                   | localhost | postmaster | default
 cron.max_running_jobs       | 32        | postmaster | default
 cron.timezone               | GMT       | postmaster | default
 cron.use_background_workers | off       | postmaster | default
 shared_preload_libraries    | pg_cron   | postmaster | configuration file
(6 rows)

Three decisions are operationally important here. First, the worker is truly loaded because shared_preload_libraries = pg_cron is now live and the cron.* settings exist. Second, the metadata database default is postgres, not stoxx, which means the scheduler catalog will live there unless cron.database_name is changed and PostgreSQL is restarted again. Third, cron.use_background_workers = off, so pg_cron will use local libpq connections by default and therefore depends on a working local-authentication path in pg_hba.conf.

Create the extension in the metadata database

Run this only after the restart has succeeded and the cron.* settings are visible. It is typically triggered by the first database-level step of scheduler enablement. The batch runs in a SQL session as a superuser, is state-changing, and creates the extension objects in exactly one database. Its purpose is to register the pg_cron catalog objects and function surface in the chosen metadata database.

Output fieldSource expressionUnit / typeMeaning
extnamepg_extension.extnamenameThe extension name installed in the current database.
extversionpg_extension.extversiontextThe installed extension version.
schema_namepg_extension.extnamespace::regnamespaceregnamespaceThe extension’s owning schema namespace record.

This batch installs pg_cron into the default metadata database and then verifies the extension row.

CREATE EXTENSION pg_cron;
 
SELECT
    extname,
    extversion,
    extnamespace::regnamespace AS schema_name
FROM pg_extension
WHERE extname = 'pg_cron';
CREATE EXTENSION
 extname | extversion | schema_name
---------+------------+-------------
 pg_cron | 1.6        | pg_catalog
(1 row)

pg_cron is now installed in the metadata database and ready to accept job definitions. The practical consequence of keeping cron.database_name = postgres is that job metadata lives there even when the actual scheduled SQL targets stoxx.

Flag or settingSyntaxDescription
-yapt-get install -y postgresql-16-cronAccept the package-install prompt non-interactively.
bash -lcdocker exec stoxx-postgres bash -lc "<command>"Execute the package-management command inside the running container shell.
shared_preload_librariesALTER SYSTEM SET shared_preload_libraries = 'pg_cron'Register pg_cron as a startup-loaded library.
cron.database_namecron.database_name = 'postgres'Choose the one database that owns pg_cron metadata.
cron.use_background_workerscron.use_background_workers = offKeep the default libpq-based job-launch model instead of background workers.

Job Creation

Once the extension exists, job creation is catalog-driven. The important PostgreSQL distinction is that job metadata may live in one database while the SQL executes in another database in the same cluster.

PostgreSQL | cron.schedule_in_database | create and inventory jobs

This subsection establishes the actual function surface, creates a harmless scheduled job against stoxx, and inspects the stored job definition in cron.job.

List the pg_cron function surface

Run this before writing production job code or delegating access to other operators, especially when the team is unsure which pg_cron signatures the currently installed version exposes. It is typically triggered by first use of the extension, upgrade validation, or review of cross-database scheduling capability. The query runs in a SQL session, is read-only, and inspects PostgreSQL’s function catalogs. Its purpose is to show the concrete job-management API rather than relying on memory or outdated examples.

Output fieldSource expressionUnit / typeMeaning
pronamepg_proc.pronamenameThe function name exposed in the cron schema.
argumentspg_get_function_identity_arguments(p.oid)textThe identity argument list for the function signature.
returnspg_get_function_result(p.oid)textThe function’s return type.

This query lists the pg_cron management functions exposed by the installed extension.

SELECT
    proname,
    pg_get_function_identity_arguments(p.oid) AS arguments,
    pg_get_function_result(p.oid) AS returns
FROM pg_proc AS p
JOIN pg_namespace AS n
    ON n.oid = p.pronamespace
WHERE n.nspname = 'cron'
ORDER BY proname, arguments;
       proname        |                                        arguments                                         | returns
----------------------+------------------------------------------------------------------------------------------+---------
 alter_job            | job_id bigint, schedule text, command text, database text, username text, active boolean | void
 job_cache_invalidate |                                                                                          | trigger
 schedule             | job_name text, schedule text, command text                                               | bigint
 schedule             | schedule text, command text                                                              | bigint
 schedule_in_database | job_name text, schedule text, command text, database text, username text, active boolean | bigint
 unschedule           | job_id bigint                                                                            | boolean
 unschedule           | job_name text                                                                            | boolean
(7 rows)

The key operational point is the presence of cron.schedule_in_database. That is the function that lets the metadata remain in the scheduler database while the real work runs in stoxx, which is the cleanest analogue to central scheduler metadata with business-database execution.

Create a harmless target table for a scheduler heartbeat

Run this when the goal is to validate scheduler behavior without touching business tables or relying on side effects that are hard to inspect. It is typically triggered by first scheduler rehearsal or by extension-validation work after enablement. The batch runs in the target application database, is state-changing, and creates a small demo table in demo_stc that later scheduled runs can write into safely. Its purpose is to make successful execution visible in ordinary SQL data, not only in scheduler metadata.

This batch creates a simple target table in stoxx for scheduled heartbeat rows.

DROP TABLE IF EXISTS demo_stc.pgcron_heartbeat;
 
CREATE TABLE demo_stc.pgcron_heartbeat
(
    heartbeat_id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    executed_at timestamptz NOT NULL,
    db_name text NOT NULL,
    role_name text NOT NULL,
    note text NOT NULL
);
DROP TABLE
CREATE TABLE
NOTICE:  table "pgcron_heartbeat" does not exist, skipping

This is deliberately low-risk. The scheduled command will later write a timestamp, database name, and role name into this table, which makes cross-database execution visible without needing to infer success from logs alone.

Register a named cross-database heartbeat job

Run this after the target-side object exists and the scheduler metadata database is ready to store jobs. It is typically triggered by the first production-style use of pg_cron, especially when the team wants metadata in postgres but execution in stoxx. The command runs in the metadata database as a sufficiently privileged PostgreSQL role, is state-changing, and inserts a row into cron.job. Its purpose is to create a named schedule that executes in stoxx as postgres every minute.

Output fieldSource expressionUnit / typeMeaning
job_idcron.schedule_in_database(...)bigintThe new job identifier stored in cron.job.

This command creates a named heartbeat job that runs every minute in stoxx.

SELECT cron.schedule_in_database
(
    'note05_pgcron_heartbeat',
    '* * * * *',
    'INSERT INTO demo_stc.pgcron_heartbeat(executed_at, db_name, role_name, note) VALUES (clock_timestamp(), current_database(), current_user, ''pg_cron note 05 heartbeat'');',
    'stoxx',
    'postgres',
    true
) AS job_id;
 job_id
--------
      1
(1 row)

The returned job_id = 1 is the durable identifier for later monitoring, alteration, and removal. The schedule string is the classic five-field cron form, so the job is expected to fire once per minute.

Inspect the stored job definition

Run this immediately after scheduling, or any time the question is “what exactly is registered right now?” rather than “what do we think the job was meant to do?” It is typically triggered by initial review, change-control verification, or troubleshooting of schedule drift. The query runs in the metadata database, is read-only, and inspects cron.job. Its purpose is to verify the stored job text, target database, execution user, and active state.

Output fieldSource columnUnit / typeMeaning
jobidcron.job.jobidbigintThe job identifier used for alteration, history joins, and removal.
jobnamecron.job.jobnametextThe optional human-readable job name.
schedulecron.job.scheduletextThe cron schedule expression.
commandcron.job.commandtextThe SQL command pg_cron will execute.
databasecron.job.databasetextThe target database in which the command runs.
usernamecron.job.usernametextThe PostgreSQL role used for the job’s execution.
activecron.job.activebooleanWhether the job is enabled for future execution.

This query shows the exact job definition stored in cron.job.

SELECT
    jobid,
    jobname,
    schedule,
    command,
    database,
    username,
    active
FROM cron.job
ORDER BY jobid;
 jobid |         jobname         | schedule  |                                                                                 command                                                                                 | database | username | active
-------+-------------------------+-----------+-------------------------------------------------------------------------------------------------------------------------------------------------------------------------+----------+----------+--------
     1 | note05_pgcron_heartbeat | * * * * * | INSERT INTO demo_stc.pgcron_heartbeat(executed_at, db_name, role_name, note) VALUES (clock_timestamp(), current_database(), current_user, 'pg_cron note 05 heartbeat'); | stoxx    | postgres | t
(1 row)

The metadata confirms the intended design: the job catalog is stored in the scheduler database, the SQL executes in stoxx, the executor is postgres, and the job is active. This is the PostgreSQL shape that replaces the SQL Server Agent distinction between central metadata and target database work.

Monitoring and History

Job registration is only the first half of scheduling. A usable scheduler note must show what success looks like, what failure looks like, and which metadata surfaces prove the difference.

PostgreSQL | cron.job_run_details | verify success, failure, and cleanup

This subsection inspects the catalog shape, confirms the row-level-security visibility model, captures successful executions, captures a controlled failure, and then removes the demo job cleanly.

Inspect the scheduler catalog layout

Run this before writing monitoring queries or delegating scheduler access, especially when the team needs to know which tables actually hold definitions versus run history. It is typically triggered by first operational review of pg_cron, by note-writing work, or by troubleshooting where the result columns are not yet familiar. The query runs in a SQL session, is read-only, and inspects information_schema.columns. Its purpose is to document the real column surface of cron.job and cron.job_run_details.

Output fieldSource columnUnit / typeMeaning
table_nameinformation_schema.columns.table_namesql_identifierThe scheduler metadata table name.
column_nameinformation_schema.columns.column_namesql_identifierThe column name exposed by the scheduler table.
data_typeinformation_schema.columns.data_typecharacter_dataThe PostgreSQL data type of that column.

This query lists the column layout of the two main scheduler metadata tables.

SELECT
    table_name,
    column_name,
    data_type
FROM information_schema.columns
WHERE table_schema = 'cron'
  AND table_name IN ('job', 'job_run_details')
ORDER BY table_name, ordinal_position;
   table_name    |  column_name   |        data_type
-----------------+----------------+--------------------------
 job             | jobid          | bigint
 job             | schedule       | text
 job             | command        | text
 job             | nodename       | text
 job             | nodeport       | integer
 job             | database       | text
 job             | username       | text
 job             | active         | boolean
 job             | jobname        | text
 job_run_details | jobid          | bigint
 job_run_details | runid          | bigint
 job_run_details | job_pid        | integer
 job_run_details | database       | text
 job_run_details | username       | text
 job_run_details | command        | text
 job_run_details | status         | text
 job_run_details | return_message | text
 job_run_details | start_time     | timestamp with time zone
 job_run_details | end_time       | timestamp with time zone
(19 rows)

cron.job is the definition table. cron.job_run_details is the execution-history table. That separation is the same operational boundary an engineer expects from any scheduler: one surface describes what should happen, the other records what actually happened.

Inspect the row-level-security policies on scheduler metadata

Run this when the security question is not “can the extension run?” but “who can see whose jobs and history?” It is typically triggered by multi-user scheduler design, audit review, or a decision to grant non-superusers access to the cron schema. The query runs in a SQL session, is read-only, and inspects pg_policies. Its purpose is to surface the visibility boundary PostgreSQL applies to cron.job and cron.job_run_details.

Output fieldSource columnUnit / typeMeaning
schemanamepg_policies.schemanamenameThe schema containing the protected table.
tablenamepg_policies.tablenamenameThe table protected by the policy.
policynamepg_policies.policynamenameThe RLS policy name.
rolespg_policies.rolesname[]The roles to which the policy applies.
cmdpg_policies.cmdtextThe command class covered by the policy.
qualpg_policies.qualtextThe predicate PostgreSQL uses to decide row visibility.

This query shows the row-level-security policies applied to the pg_cron metadata tables.

SELECT
    schemaname,
    tablename,
    policyname,
    roles,
    cmd,
    qual
FROM pg_policies
WHERE schemaname = 'cron'
ORDER BY tablename, policyname;
 schemaname |    tablename    |         policyname          |  roles   | cmd |           qual
------------+-----------------+-----------------------------+----------+-----+---------------------------
 cron       | job             | cron_job_policy             | {public} | ALL | (username = CURRENT_USER)
 cron       | job_run_details | cron_job_run_details_policy | {public} | ALL | (username = CURRENT_USER)
(2 rows)

This is a critical PostgreSQL-specific security boundary. The cron tables are not wide-open inventories by default. They are protected so ordinary users only see rows where username = CURRENT_USER, which is the closest analogue to “operators can manage only the jobs they own.”

Confirm that no run has happened yet

Run this immediately after job registration when the next question is whether the first execution has actually occurred. It is typically triggered by a wait for the next minute boundary or by validation that the scheduler is not backfilling unexpectedly. The query runs in the target database, is read-only, and inspects the heartbeat table created for this note. Its purpose is to prove the pre-execution state before success or failure is interpreted.

Output fieldSource expressionUnit / typeMeaning
heartbeat_rowsCOUNT(*)bigintThe number of rows currently written by the scheduled heartbeat job.

This query verifies that the heartbeat table is still empty before the first minute boundary is reached.

SELECT COUNT(*) AS heartbeat_rows
FROM demo_stc.pgcron_heartbeat;
 heartbeat_rows
----------------
              0
(1 row)

The zero-row result is the expected state immediately after scheduling and before the first firing time is reached.

Inspect the successful heartbeat rows in the target database

Run this after allowing the schedule to cross one or more minute boundaries and when the goal is to prove actual SQL execution in the target database rather than only metadata updates. It is typically triggered by first scheduler validation or by a question about whether cross-database execution is working. The query runs in stoxx, is read-only, and inspects the target table the job writes to. Its purpose is to prove that scheduled SQL executed in the intended database as the intended role.

Output fieldSource columnUnit / typeMeaning
heartbeat_iddemo_stc.pgcron_heartbeat.heartbeat_idbigintThe identity key for each recorded run.
executed_atdemo_stc.pgcron_heartbeat.executed_attimestamptzThe wall-clock time at which the scheduled statement executed.
db_namedemo_stc.pgcron_heartbeat.db_nametextThe database name seen by the executing session.
role_namedemo_stc.pgcron_heartbeat.role_nametextThe SQL role under which the job executed.
notedemo_stc.pgcron_heartbeat.notetextThe static marker written by the scheduled statement.

This query shows the rows written by the scheduled heartbeat job in stoxx.

SELECT
    heartbeat_id,
    executed_at,
    db_name,
    role_name,
    note
FROM demo_stc.pgcron_heartbeat
ORDER BY heartbeat_id;
 heartbeat_id |          executed_at          | db_name | role_name |           note
--------------+-------------------------------+---------+-----------+---------------------------
            1 | 2026-04-18 22:48:00.010829+00 | stoxx   | postgres  | pg_cron note 05 heartbeat
            2 | 2026-04-18 22:49:00.005062+00 | stoxx   | postgres  | pg_cron note 05 heartbeat
(2 rows)

These rows prove the job is not merely “scheduled.” It is executing in stoxx, seeing current_database() = stoxx, and running as postgres. The timestamps also show the expected once-per-minute cadence.

Inspect the successful run-history rows

Run this alongside the target-table check when the operational need is to confirm scheduler history, not only application-side effects. It is typically triggered by monitoring design or by incident review when the business table is not enough evidence on its own. The query runs in the metadata database, is read-only, and inspects cron.job_run_details. Its purpose is to show how pg_cron records success, duration, and server feedback for each run.

Output fieldSource columnUnit / typeMeaning
jobidcron.job_run_details.jobidbigintThe scheduled job that produced this run.
runidcron.job_run_details.runidbigintThe unique execution-attempt identifier.
databasecron.job_run_details.databasetextThe target database for the run.
usernamecron.job_run_details.usernametextThe PostgreSQL role used for execution.
statuscron.job_run_details.statustextThe run outcome, such as succeeded or failed.
return_messagecron.job_run_details.return_messagetextPostgreSQL’s returned command tag or error text.
start_timecron.job_run_details.start_timetimestamptzWhen the run started.
end_timecron.job_run_details.end_timetimestamptzWhen the run ended.

This query shows the scheduler’s recorded history for the successful heartbeat runs.

SELECT
    jobid,
    runid,
    database,
    username,
    status,
    return_message,
    start_time,
    end_time
FROM cron.job_run_details
ORDER BY runid DESC
LIMIT 5;
 jobid | runid | database | username |  status   | return_message |          start_time          |           end_time
-------+-------+----------+----------+-----------+----------------+------------------------------+-------------------------------
     1 |     1 | stoxx    | postgres | succeeded | INSERT 0 1     | 2026-04-18 22:48:00.01005+00 | 2026-04-18 22:48:00.011453+00
(1 row)

On a successful DML job, return_message is the PostgreSQL command tag. Here it reports INSERT 0 1, which means the scheduler saw one inserted row and recorded the run as succeeded.

Change the job command to a controlled failure

Run this only in a sandbox or a deliberately isolated demo when the goal is to validate failure visibility. It is typically triggered by monitoring rehearsal, not by ordinary administration. The command runs in the metadata database, is state-changing, and changes the stored SQL for future executions. Its purpose is to force a predictable error on the next run so the history table can be interpreted against a real failure.

This command changes the scheduled SQL to SELECT 1/0; so the next execution fails deterministically.

SELECT cron.alter_job(1, command := 'SELECT 1/0;');
 alter_job
-----------
 
(1 row)

Verify the altered job definition before the next run

Run this immediately after cron.alter_job when the next execution outcome must be interpreted against the exact stored command text. It is typically triggered by change review or failure rehearsal. The query runs in the metadata database, is read-only, and inspects cron.job. Its purpose is to prove that the job now carries the failing SQL text and is still active.

This query confirms the altered command text stored for job 1.

SELECT
    jobid,
    jobname,
    schedule,
    command,
    database,
    username,
    active
FROM cron.job
WHERE jobid = 1;
 jobid |         jobname         | schedule  |   command   | database | username | active
-------+-------------------------+-----------+-------------+----------+----------+--------
     1 | note05_pgcron_heartbeat | * * * * * | SELECT 1/0; | stoxx    | postgres | t
(1 row)

Inspect the resulting failed run history

Run this after the next minute boundary once the intentionally failing job has had time to execute. It is typically triggered by a need to verify alerting logic, monitoring dashboards, or triage queries against a known-bad run. The query runs in the metadata database, is read-only, and inspects cron.job_run_details. Its purpose is to show what pg_cron records when a scheduled statement throws a PostgreSQL error.

This query shows the most recent run-history rows after the command was altered to fail.

SELECT
    jobid,
    runid,
    database,
    username,
    status,
    return_message,
    start_time,
    end_time
FROM cron.job_run_details
ORDER BY runid DESC
LIMIT 5;
 jobid | runid | database | username |  status   |      return_message      |          start_time           |           end_time
-------+-------+----------+----------+-----------+--------------------------+-------------------------------+-------------------------------
     1 |     3 | stoxx    | postgres | failed    | ERROR:  division by zero+| 2026-04-18 22:50:00.006885+00 | 2026-04-18 22:50:00.007958+00
     1 |     2 | stoxx    | postgres | succeeded | INSERT 0 1               | 2026-04-18 22:49:00.004794+00 | 2026-04-18 22:49:00.005939+00
     1 |     1 | stoxx    | postgres | succeeded | INSERT 0 1               | 2026-04-18 22:48:00.01005+00  | 2026-04-18 22:48:00.011453+00
(3 rows)

This is the monitoring payoff. The same job now has two successful runs followed by a failed run, and return_message carries the real PostgreSQL error text. The history table is therefore sufficient to distinguish “job fired and failed” from “job never fired.”

Remove the demo job from the scheduler metadata

Run this after the monitoring rehearsal is complete and the scheduled object should no longer exist. It is typically triggered by teardown of test jobs or by replacement of an obsolete job definition. The first query runs in the metadata database, is state-changing, and removes the job by id. The second query is read-only and confirms that the catalog is empty afterward. Their purpose is to demonstrate clean scheduler teardown rather than leaving test jobs behind.

Output fieldSource expressionUnit / typeMeaning
removedcron.unschedule(1)booleanWhether the job row was removed successfully.

This command removes the demo job from cron.job.

SELECT cron.unschedule(1) AS removed;
 removed
---------
 t
(1 row)

This query confirms that no scheduler rows remain after removal.

SELECT COUNT(*) AS remaining_jobs
FROM cron.job;
 remaining_jobs
----------------
              0
(1 row)

Remove the target table used by the demo job

Run this after the scheduler metadata has been cleaned up and the target-side demo object is no longer needed. It is typically triggered by teardown of rehearsal artifacts. The batch runs in stoxx, is state-changing, and drops the target table. Its purpose is to return the business database to its pre-demo state.

This batch removes the target table created for the heartbeat demo.

DROP TABLE IF EXISTS demo_stc.pgcron_heartbeat;
DROP TABLE

Execution Boundaries and Notifications

pg_cron is not a full scheduler platform. It has job definitions and run history, but it does not provide SQL Server Agent-style operators, notification bindings, categories, or a proxy subsystem. Those boundaries must be understood before pg_cron is treated as a production scheduler.

PostgreSQL | scheduler boundaries | understand what pg_cron does and does not own

The most important operational distinction is the execution model. By default pg_cron opens local libpq connections to run jobs, so scheduler success still depends on the local authentication path. Visibility is restricted through row-level security, and alerting normally comes from PostgreSQL logging plus an external monitoring or orchestration layer.

Three practical consequences follow from the live scheduler surface captured in this note:

  • job execution is tied to a PostgreSQL role, not to an external credential proxy
  • history is visible in cron.job_run_details, but paging humans still requires PostgreSQL logs, external monitoring, or an orchestrator
  • organization depends on clear jobname values and role design rather than on built-in job categories or notification operators

PostgreSQL therefore gives the database-local scheduling surface, not the human-notification surface. For production use, pair pg_cron with log collection, metrics, or an external scheduler that can alert on missed or failed runs.

Choosing the Right Scheduler

The same question at the end of the SQL Server Agent note still matters here: should the database own this schedule at all? PostgreSQL changes the answer because the in-database scheduler is optional and extension-based rather than built into core.

PostgreSQL | pg_cron vs orchestrator | choose the correct owner for scheduled work

Decision tree


flowchart TD
    START([Scheduled work to own]) --> Q1{Is the work<br/>SQL-only inside one<br/>PostgreSQL cluster?}
    Q1 -->|YES| YES1[YES]
    Q1 -->|NO| NO1[NO]
    YES1 --> Q2{Does it need only<br/>time-based execution<br/>without a DAG?}
    Q2 -->|YES| YES2[YES]
    Q2 -->|NO| NO2[NO]
    YES2 --> Q3{Can the cluster safely<br/>host pg_cron and the<br/>required auth path?}
    Q3 -->|YES| YES3[YES]
    Q3 -->|NO| NO3[NO]
    YES3 --> PGCRON[pg_cron]
    NO3 --> ORCH[External orchestrator<br/>Airflow / Dagster / Prefect]
    NO2 --> ORCH
    NO1 --> Q4{Is it host-local shell,<br/>filesystem, or service work?}
    Q4 -->|YES| YES4[YES]
    Q4 -->|NO| NO4[NO]
    YES4 --> HOST[cron / systemd timer]
    NO4 --> ORCH

    classDef yesNode fill:#1f3b2d,stroke:#73d13d,color:#c0caf5
    classDef noNode fill:#4a1f24,stroke:#db4b4b,color:#c0caf5
    classDef pgcronNode fill:#1a3a52,stroke:#7aa2f7,color:#c0caf5
    classDef orchNode fill:#3d2952,stroke:#bb9af7,color:#c0caf5
    class YES1,YES2,YES3,YES4 yesNode
    class NO1,NO2,NO3,NO4 noNode
    class PGCRON pgcronNode
    class ORCH,HOST orchNode

The decision tree deliberately narrows pg_cron to the same ownership domain where it is strongest: SQL-only work inside one PostgreSQL cluster, triggered by time rather than by cross-system dependencies. Host schedulers keep their place for shell and filesystem work, and full orchestrators keep their place for DAGs, retries across systems, and human-facing alerting.

When pg_cron is the right tool

PatternReason
Lightweight table maintenance in one PostgreSQL clusterThe work is SQL-only, cluster-local, and time-based.
Periodic refresh of summary tables or materialized viewsThe data and the scheduler boundary both stay inside PostgreSQL.
Retention deletes, archival flags, or routine housekeeping SQLThe command is short, deterministic, and easy to validate from cron.job_run_details.
Small heartbeat or observability writesThe success condition is visible directly in PostgreSQL data and metadata.
Scheduled invocation of a stored procedure or functionThe job surface is a single SQL call with no external dependency graph.

When pg_cron is the wrong tool

PatternBetter ownerReason
Multi-step ETL crossing PostgreSQL, object storage, and APIsAirflow / Dagster / PrefectThe workflow needs DAG semantics, retries, and external observability.
Shell scripts, file rotation, backups to host paths, or service restartscron / systemd timerThe work belongs to the host, not to PostgreSQL SQL execution.
Pipelines that need alert routing, SLA tracking, or dependency visualizationExternal orchestratorpg_cron has no built-in operator, notification, or DAG model.
Workloads requiring different OS credentials or secret scopesExternal orchestrator or host schedulerpg_cron executes as a PostgreSQL role, not as an OS-level proxy.
Long-running jobs that would compete heavily with normal database workloadExternal orchestratorThe scheduler and the workload would contend for the same database resources.