A scheduled query is easy to forget until the table it maintains stops changing. At that point, “the job is active” is not enough. You need to know whether it ran, how long it took, and what happened.
The DuckDB 2.0 update to Cronjob adds that information. It also fixes several cases where the scheduler did something quite different from what the SQL requested: planning could create a job, rows could share the wrong schedule, and an impossible date could turn into a busy loop.
Keep the job ID, then inspect the runs
This example records a heartbeat once a minute. Run it in native DuckDB and keep the database open:
CREATE TABLE heartbeat (recorded_at TIMESTAMPTZ DEFAULT now());
SET VARIABLE heartbeat_job = ( SELECT cron('INSERT INTO heartbeat DEFAULT VALUES', '0 * * * * *'));The six schedule fields begin with seconds. 0 * * * * * means the start of each minute, evaluated in UTC. A five-field crontab expression is an error.
After the job has had time to run, inspect it:
SELECT job_id, status, next_run, last_run, run_count, error_countFROM cron_jobs()WHERE job_id = getvariable('heartbeat_job');
SELECT started_at, finished_at, finished_at - started_at AS elapsed, success, resultFROM cron_job_history()WHERE job_id = getvariable('heartbeat_job')ORDER BY started_at DESC;The history retains the last 100 runs per job. result reports success or the query error, and success lets you filter failed runs directly. The counters on cron_jobs() show total runs and errors for the job, even as older history entries fall out of the retained window.
When finished, remove the example job:
SELECT cron_delete(getvariable('heartbeat_job'));Deleting a job also removes its history. If you need a durable audit, copy the history somewhere persistent before deleting the job or closing the database.
Timestamps that SQL can use
Previously, next_run and last_run were strings formatted with ctime(). They used local time even though the schedule was evaluated in UTC. Comparing them, subtracting them or joining them to another timestamp required parsing a display format.
Both columns now return TIMESTAMPTZ. last_run is the start of the most recent run, and next_run is NULL for an inactive job. The new created_at column records when the job was registered.
This is a return-type change. Update clients that expect text, and remove any parsing written around the old output. Normal timestamp formatting still follows the connection’s time zone; the cron expression itself continues to use UTC.
A side effect should happen when the query runs
cron() registers work. Treating it as a function the optimizer can evaluate once is wrong even when its arguments are constants.
The old registration allowed constant folding. An EXPLAIN or PREPARE could schedule a job while planning, and a query over many rows could create only one job. cron() and cron_delete() are now marked volatile. Planning does not execute them, and each evaluated row performs its own operation.
That matters for queries that read a table of job definitions. The scheduler now reads both the query and schedule from each row; it previously reused row zero’s schedule for a whole chunk. A NULL query or schedule returns NULL and registers nothing.
There is also a practical consequence: a query that calls cron() over 3,000 rows now schedules 3,000 jobs. Use one row when you mean one job. Listing a large job collection is fixed too: cron_jobs() emits multiple chunks instead of writing past one chunk’s capacity.
Reject dates that will never happen
A cron expression can have valid fields and still describe no real date:
SELECT cron('SELECT 1', '0 0 0 30 2 *');Previously, the scheduler could interpret the failed search for February 30 as a reason to run immediately, repeatedly. It now rejects the schedule. Jobs that exhaust their future dates are deactivated.
SQL is syntax-checked when a job is added, so an empty query or a misspelled SELECT fails immediately. That does not guarantee the query will succeed later: a referenced table can be absent or change before execution. Those failures belong in the run history.
The scheduler change includes execution tests for successful and failed jobs, a job inspecting cron_jobs() while running, and a job deleting itself. The scheduler is owned by the database and stops when it closes.
Cronjob still runs inside a live DuckDB process. It does not persist schedules across restarts or start DuckDB for you. For those jobs, use an external scheduler. For an application that already keeps DuckDB open, the update makes the work happening inside it much easier to see.