Skip to content

Cron Jobs

Run tasks on a recurring cadence. Pick one scheduler — application-side beat, or Postgres-side pg_cron. Both read the same SCHEDULE. Installing the pg_cron app makes absurd_beat and absurd_worker --beat raise CommandError.

Absurd's cron patterns.

Declare a schedule

settings.py
TASKS = {
    "default": {
        "BACKEND": "django_absurd.backends.AbsurdBackend",
        "OPTIONS": {
            "SCHEDULE": {
                "nightly-report": {
                    "task": "myapp.tasks.send_report",  # dotted path to a @task
                    "cron": "0 2 * * *",  # 2am daily
                },
                "heartbeat": {
                    "task": "myapp.tasks.ping",
                    "cron": "*/5 * * * *",
                    "queue": "monitoring",  # optional; must be declared
                    "kwargs": {"source": "beat"},  # optional
                },
            },
        },
    },
}
Key Required Description
task yes Dotted import path to a @task function.
cron yes Cron expression — grammar differs per scheduler, see below.
queue no Queue to enqueue on; defaults to the backend's. Must be declared.
args / kwargs no Passed to the task each firing. args a JSON array, kwargs a JSON object.

Validated by manage.py check (absurd.E007); names are limited to [A-Za-z0-9_-].

Application-side (beat)

python manage.py absurd_beat          # or co-located: absurd_worker --beat

Beat enqueues each task when its slot comes due; a worker then runs it like any other.

  • Run exactly one. No leader election — concurrent beats each fire every slot.
  • Never backfills. A slot missed while down is skipped.
  • A failed enqueue is not retried either. Beat logs the error and moves to the next slot, so an unprovisioned target queue costs you every slot until someone provisions it.
  • Grammar is croniter: 5-field, or 6-field with a leading seconds column ("*/30 * * * * *").
  • Expressions use Django's TIME_ZONE.

Runnable demo: examples/beat/.

Postgres-side (pg_cron)

settings.py
INSTALLED_APPS = [
    # ...
    "django_absurd",
    "django_absurd.pg_cron",  # must come AFTER "django_absurd"
]
python manage.py migrate

Postgres fires the schedule directly — no beat process. migrate reconciles SCHEDULE into pg_cron jobs and your existing workers run the tasks. A settings-only change needs no new migration, so "migrate on deploy" covers it. The extension itself is one-time operator setup.

  • Grammar is pg_cron's own: 5-field cron, <n> seconds (1–59) for sub-minute cadence, or an @daily-style alias. Validated by manage.py check.
  • Beat's 6-field form and # are refused, though pg_cron accepts them: its parser reads five fields and takes the rest as the command, so "*/30 * * * * *" would schedule "*/30 * * * *" — a cadence you didn't write, reported as valid.
  • Timezone is the cron.timezone GUC (Grand Unified Configuration — Postgres's term for a server setting), default GMT — not Django's TIME_ZONE. Set it to match if yours is non-UTC.
  • To stop a job, remove it from SCHEDULE. Every reconcile re-arms settings-owned jobs, so disabling one directly in cron.job doesn't survive the next deploy.
  • absurd.W003 if the app is ordered before "django_absurd".

Runnable demo: examples/pg_cron/.

How jobs reach your database

pg_cron is cluster-wide: only the database named by cron.database_name may hold the extension, and yours probably isn't it. django-absurd finds that database itself and schedules each job cross-database — nothing to configure either way.

Reconcile without migrating

python manage.py absurd_sync_crons

For pipelines that skip migrate when no migration files changed. Reports synced/pruned counts, non-zero exit on error.

  • Always connect as the same role. pg_cron keys jobs on (jobname, username) and runs each as its scheduling role, so mixing roles duplicates jobs and breaks pruning.

Author schedules in the admin

Admins author their own schedules alongside the settings-declared ones — see Admin.

Test databases

settings.py
"OPTIONS": {"PG_CRON_ON_TEST_DB": True}  # opt in; off by default

Every cron.* write is inert on a test database, detected automatically — otherwise a leftover schedule would fire for real against test data.

Option Default Effect
PG_CRON_ON_TEST_DB False The opt-in. Without it writes no-op and absurd_sync_crons refuses to run.
SYNC_SCHEDULES_ON_MIGRATE True migrate's automatic reconcile against a real database.
SYNC_SCHEDULES_ON_TEST_DB False Same, against a test database. Setting it without PG_CRON_ON_TEST_DB is absurd.E011.

Testing — getting a SCHEDULE into pg_cron.

Uninstall

python manage.py absurd_sync_crons --teardown   # --noinput in automation

Run this before removing "django_absurd.pg_cron" from INSTALLED_APPS or switching back to beat. Removing the app stops the reconcile but leaves the jobs firing, and nothing cleans up afterwards.

  • It unschedules every owned job and deletes its row, admin-authored included — otherwise the next migrate resurrects them. Hence the confirmation prompt.
  • migrate alone never tears down admin-authored jobs, so it is not a substitute.

Operator setup

One-time, on the central database named by cron.database_name — not necessarily the Absurd one. See pg_cron's own docs.

  • pg_cron ≥ 1.4 — reconciles call cron.schedule_in_database and cron.alter_job.
  • shared_preload_libraries = pg_cron — needs a server restart.
  • CREATE EXTENSION pg_cron.
  • Grants, unless the scheduling role owns the extension. alter_job is not optional — schedule_in_database only applies active on first create:
GRANT CONNECT ON DATABASE <central_database> TO <scheduling_role>;
GRANT USAGE ON SCHEMA cron TO <scheduling_role>;
GRANT EXECUTE ON FUNCTION
    cron.schedule_in_database(text, text, text, text, text, boolean)
    TO <scheduling_role>;
GRANT EXECUTE ON FUNCTION
    cron.alter_job(bigint, text, text, text, text, boolean)
    TO <scheduling_role>;
GRANT EXECUTE ON FUNCTION cron.unschedule(bigint) TO <scheduling_role>;

CONNECT applies even though none of your tables live there — the central connection reuses the app's own credentials and swaps only the database name.

  • A scheduling role that isn't a superuser needs one grant more, or migrate fails:
GRANT pg_read_all_settings TO <scheduling_role>;

Finding the central database means reading the cron.database_name GUC, which pg_cron marks superuser-only. Without it: permission denied to examine "cron.database_name". Not specific to managed Postgres — it applies on any server; local setups miss it only because they connect as postgres.

Managed Postgres (RDS, Cloud SQL, Azure) exposes these as parameter-group flags. manage.py check reports absurd.E012 if the central database is unreachable or missing the extension. It stays quiet under a test suite unless PG_CRON_ON_TEST_DB opts that database in.

Also worth scheduling: a cron.job_run_details purge. It's the only place fire-time failures show up, and it grows unbounded.

Docker

The stock postgres image ships no pg_cron. Copy django-absurd's own — its pg_cron suite runs against exactly these:

  • Dockerfile.pg_cron — Debian base (Alpine has no pg_cron package), the PGDG package, and an initdb script that creates the extension on the central database.
  • The db_pg_cron service in compose.yaml — the shared_preload_libraries and cron.database_name server flags, which can't live in the image.