Skip to main content

Django Ticks

Periodic jobs for Django, declared in code and remembered in one database table. Run a tick from cron, as a daemon, or as many daemons as you like: the process holds no state.

# myapp/ticks.py
from datetime import timedelta

from django_ticks.models import register_job

from .stats import rebuild_stats


register_job(rebuild_stats, timedelta(hours=1))
$ ./manage.py tick             # run whatever is due, then exit
$ ./manage.py tick --forever   # keep running, sleep until the next job is due

The idea

The result of a tick is a function of the code, the table and the clock. The process running it is interchangeable.

  • Code declares. A job is a register_job call executed at import time: a handler, an interval, and a key that defaults to the handler's module path and name. The database cannot create or edit a job, and the admin is read-only. Adding a job is a code change and a deploy; its row appears on the first tick.
  • The database remembers. One row per job, holding last_run. That is all the state. A daily job does not fire again because a container restarted, and a job added a year ago does not fire twice because two deploys overlapped. Think of django_migrations: the code is the truth, the table is a bookmark.
  • The database coordinates. A tick locks the job's row for the duration of the handler (SELECT ... FOR UPDATE), so any number of processes can tick at once and a job still runs in one place at a time. No broker, no leader election, no lock file.
  • The process is disposable. tick runs what is due and exits, which suits cron, a Kubernetes CronJob or Heroku Scheduler. tick --forever sleeps until the next job is due, which suits a long-running container. Both at once, or five of the latter, is fine too. run() also accepts a stop_event, so the loop can live in a thread of a process you already have.

A handler runs inside the transaction that holds the row lock, in a savepoint of its own: its writes commit together with the last_run update, and if it raises, its writes roll back, the error is logged, and last_run still advances, so the job runs again after its interval rather than every tick. Keep handlers short: a long handler holds its row lock and an open transaction for as long as it runs. Heavy work belongs in a task queue the handler merely feeds.

Deliberately not here

  • Run history. One row per job, not per run. Your logs have the history.
  • Retries. A failing job is retried by its own interval. A retry loop on a job that ticks every second is worse than a skipped run.
  • Cron expressions and clock alignment. Jobs run at "last run plus interval", not "at 03:00". The next run is computed in one place (run and run_job in models.py), so clock-aligned schedules could be added without changing the model; they have not been needed.
  • Per-job parallelism. A job runs in one process at a time. That is what the lock is for.
  • A task queue. There are no workers, no queues, no payloads. For one-shot work with retries and dependencies, django-goals is built on the same database-only conviction; the two packages are independent.

Requirements

  • PostgreSQL. The row lock uses select_for_update(no_key=True), which Django supports on PostgreSQL only.
  • Django 4.2 or later, Python 3.10 or later.

Installation

pip install django-ticks

Add django_ticks to INSTALLED_APPS and run migrate.

Declaring jobs

register_job(handler, interval, key=None) records a job in the current process. The handler is called as handler(now=...) with the timestamp the tick started at. Registrations must have happened before run() starts, so put them in a module imported from your AppConfig.ready:

class MyAppConfig(AppConfig):
    name = 'myapp'

    def ready(self):
        import myapp.ticks  # noqa: F401

The key identifies the job's row. It defaults to module.function, so renaming or moving a handler creates a fresh row that runs on the next tick as if it had never run. Pass key= to keep the identity stable across a rename, or when the handler has no useful name:

register_job(
    lambda now: call_command('clearsessions'),
    interval=timedelta(days=1),
    key='clearsessions',
)

Rows of jobs that no longer exist in code are left alone. The admin is read-only, so drop them from a shell if they bother you.

Running

./manage.py tick runs every job that is due and exits. ./manage.py tick --forever keeps running, sleeping until the next job is due. Stop it with SIGTERM or Ctrl-C. A handler interrupted mid-run is rolled back together with its last_run update, so the job runs again on the next tick.

To embed the loop in another process, call run directly:

import threading

from django_ticks.models import run

stop_event = threading.Event()
threading.Thread(target=run, kwargs={'stop_event': stop_event}).start()
...
stop_event.set()  # the loop wakes from its sleep and returns

Admin

The Job model is registered in the admin as a read-only list: keys and their last_run. It exists to look, not to configure.

Development

The test suite needs a PostgreSQL database.

cp example.env .env   # then point DATABASE_URL at your database
poetry install
poetry run pytest
poetry run flake8

Metadata

Release files for django-ticks 0.1.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for django-ticks 0.1.0
File Size Uploaded Provenance Actions
django_ticks-0.1.0.tar.gz 7.4 kB With provenance Details for django_ticks-0.1.0.tar.gz

Built distribution (wheel)

Table of built distributions (wheels) for django-ticks 0.1.0
File Interpreter ABI Platform Actions
django_ticks-0.1.0-py3-none-any.whl Python 3 none any Details for django_ticks-0.1.0-py3-none-any.whl

Total release size: 17.4 kB

Release files / django_ticks-0.1.0.tar.gz

Download URL django_ticks-0.1.0.tar.gz
Size 7.4 kB
Tags Source
SHA-256 checksum
How to use checksums
9fffc6f58276c442aae5b04e0e92635c55e62510b6699dc3f2f5cc876b4a7e6c
BLAKE2b-256 checksum
How to use checksums
6ff308168fc8176efdb35fb94487f0633ce68d5cb367131ee935781b4cbfdae7
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Sep 2, 2026.

Transparency log

Release files / django_ticks-0.1.0-py3-none-any.whl

Download URL django_ticks-0.1.0-py3-none-any.whl
Size 10.0 kB
Tags Python 3
SHA-256 checksum
How to use checksums
5ad2d2519cb26a8f24d79826d32ba54cc23b97ef55e79acbb9835e6e491b2b58
BLAKE2b-256 checksum
How to use checksums
092a71d9820b9885583d7b3465c223d2b180ce0a7f9dde8491b77c9b686d193d
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Sep 2, 2026.

Transparency log

Security for django-ticks 0.1.0

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

Full provenance

All 2 files in this release have been uploaded with provenance metadata.

Select from 2 files

Attestation for django_ticks-0.1.0.tar.gz

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Sep 2, 2026.

Transparency log

A note on package security

PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.

To learn more about protecting yourself from malicious actors, visit our package security documentation.

Release history Release notifications | RSS feed

This release

0.1.0 This release

2 release files Full provenance

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page