# django-celery-beat trades a schedule file for four database tables

> django-celery-beat is a Celery scheduler extension that stores the periodic task schedule in your database and manages it from the Django Admin, so schedules survive deploys and can be edited without a code change. The catch is a change-counter table you must remember to update yourself when you edit tasks in bulk.

**celery/django-celery-beat** — Celery Periodic Tasks backed by the Django ORM

- Repository: https://github.com/celery/django-celery-beat
- Stars: 1,956 · Forks: 501
- Language: Python
- License: NOASSERTION
- Published: 2026-10-16 · Updated: 2026-10-16 · Language: en
- Canonical page: https://hysenlabs.com/projects/celery-django-celery-beat

## The schedule moves from a file into your database

django-celery-beat moves that list into the database instead. The stated purpose is that the periodic task schedule is stored in the database, and that tasks can be managed from the Django Admin interface where you can create, edit and delete periodic tasks and how often they run.

That is the whole proposition, and it is a real operational difference. A nightly export job that someone needs to move from 02:00 to 04:00 can be changed by an operations person through an admin form, at any hour, without anyone opening a repository.

The mechanism on the Celery side is a custom scheduler class. The README points to the Celery documentation section on using custom scheduler classes for installation and usage instructions, and the docker-compose file in this repository shows exactly what that looks like in practice, with the beat command run as:

```
python3 -m celery -A mysite beat -l info --scheduler django_celery_beat.schedulers:DatabaseScheduler
```

What changes is where the timing lives, and the requirement that the beat process and at least one worker both be running, since the periodic tasks still need workers to execute them.

That last point is stated plainly: both the worker and the beat services need to be running at the same time.

## Four models, and one of them is only a counter

The data model has four classes and they divide into three that hold your configuration and one that holds nothing but a change signal.

`PeriodicTask` defines a single periodic task to be run. It must be associated with a schedule, which defines how often the task should run. It also carries the task name, a human-readable name, and optionally arguments, keyword arguments, a queue and an expiry time.

`IntervalSchedule` is a schedule that runs at a specific interval, with the example given being every five seconds. Its fields are a period type and a frequency. The available period types are `IntervalSchedule.DAYS`, `HOURS`, `MINUTES`, `SECONDS` and `MICROSECONDS`, and there is a `PERIOD_CHOICES` tuple available if you need to present the options to a user.

`CrontabSchedule` has fields corresponding to cron entries: `minute`, `hour`, `day_of_week`, `day_of_month` and `month_of_year`, plus a timezone. The README gives the mapping to a crontab expression directly, saying that the equivalent of `30 * * * *` means specifying minute `30` and `*` for the rest.

`PeriodicTasks` is the one that surprises people. It is only used as an index to keep track of when the schedule has changed. Whenever you update a `PeriodicTask`, a counter in this table is incremented, and that is what tells the celery beat service to reload the schedule from the database.

So the beat process is not watching your tables. It is watching a counter, and it reloads when the counter moves. Understanding that is the difference between a deployment that picks up a schedule change and one that does not.

## Bulk updates are the trap the README warns about

Because the reload trigger is a counter rather than a table watch, any update that bypasses the model layer will not trigger a reload.

The README states the consequence directly: if you update periodic tasks in bulk, you need to update the counter manually.

```python
>>> from django_celery_beat.models import PeriodicTasks
>>> PeriodicTasks.update_changed()
```

That one line is what a management command, a data migration or a script needs to end with after calling `queryset.update()` on `PeriodicTask`. Calling `save()` on each object works, because that is the model layer path, but it is slow for large sets and easy to forget in a shell.

The same counter appears in the timezone warning, which makes the two pieces of documentation fit together. The warning is that if you change the Django `TIME_ZONE` setting, the periodic task schedule will still be based on the old timezone, because the schedules already stored carry the timezone they were created with.

The documented fix resets the last run time for every periodic task and then signals the change:

```python
>>> from django_celery_beat.models import PeriodicTask, PeriodicTasks
>>> PeriodicTask.objects.all().update(last_run_at=None)
>>> PeriodicTasks.update_changed()
```

The note explains what that costs: it resets the state as if the periodic tasks had never run before. So after changing `TIME_ZONE`, every task may fire immediately on the next beat tick, which is fine for a nightly job and expensive for something that sends email.

The per-schedule timezone on `CrontabSchedule` is the more targeted answer, since it means one schedule can carry `Canada/Pacific` while another carries something else, without touching the global setting.

## Creating tasks means JSON-encoding the arguments yourself

The examples are written as interactive shell sessions, which makes them easy to follow and easy to copy.

An interval schedule is created or fetched with `get_or_create`, passing a frequency and a period type:

```python
>>> from django_celery_beat.models import PeriodicTask, IntervalSchedule

# executes every 10 seconds.
>>> schedule, created = IntervalSchedule.objects.get_or_create(
...     every=10,
...     period=IntervalSchedule.SECONDS,
... )
```

There is a note worth internalising: if you have multiple periodic tasks executing every ten seconds, they should all point to the same schedule object. Schedules are shared rows, not duplicated per task, so `get_or_create` rather than `create` is the right call, and creating a second identical `IntervalSchedule` row would fragment your configuration.

The task then references that schedule:

```python
>>> PeriodicTask.objects.create(
...     interval=schedule,
...     name='Importing contacts',
...     task='proj.tasks.import_contacts',
... )
```

The more complete version shows the detail that bites people. Arguments and keyword arguments are stored as JSON, so you serialise them yourself:

```python
>>> import json
>>> from datetime import datetime, timedelta

>>> PeriodicTask.objects.create(
...     interval=schedule,
...     name='Importing contacts',
...     task='proj.tasks.import_contacts',
...     args=json.dumps(['arg1', 'arg2']),
...     kwargs=json.dumps({
...        'be_careful': True,
...     }),
...     expires=datetime.utcnow() + timedelta(seconds=30)
... )
```

You can also route low level AMQP directly with the `exchange` and `routing_key` fields rather than naming a queue, and a task can be switched off temporarily by setting `enabled` to False and saving.

For a crontab, the only difference in the task creation is `crontab=schedule` instead of `interval=schedule`.

## The test stack spans Django 3.2 through 6.1

The supported version matrix is in setup.py's classifiers rather than in prose, and it is worth reading.

Python is declared from 3.8 up through 3.15, and both CPython and PyPy are listed. Django runs from 3.2 through 6.1, covering 3.2, 4.1, 4.2, 5.0, 5.1, 5.2, 6.0 and 6.1.

The development status classifier is 5 - Production/Stable, which is a stronger statement than most Django-adjacent packages make, and the licence classifier says BSD.

The linting configuration in pyproject.toml is unusually broad. Ruff is the linter with a target version of py38, and many rule families are enabled, from flake8-builtins and Airflow rules through flake8-django, pyupgrade, flake8-bandit and flynt. Notably long, several families are enabled and then commented out, including pydocstyle, flake8-bugbear, isort, flake8-boolean-trap and flake8-use-pathlib.

That is a deliberate choice rather than an oversight: enabling all of them at once would mean a large diff with no behaviour change, so a project with a long history enables the safe families first and uncomments the rest as it deals with them.

The repository layout shows the testing and development setup. There is a tox.ini, a Makefile, a docker-compose.yml, docker images for the base, Django and celery-beat services, and a manage.py at the root, so the project's own development environment runs a real PostgreSQL and a real RabbitMQ rather than mocking the broker.

The docker-compose setup is itself a usable example, wiring the scheduler as `celery -A mysite beat` with the DatabaseScheduler, a Django dev server on port 58000, and RabbitMQ on 5672.

## Conclusion

Adopt django-celery-beat if non-developers need to change a schedule in production without a deploy, since the Django Admin is the payoff. Skip it if your schedules are fixed in code and reviewed like code, because a database-backed schedule is easier to change and easier to get wrong. Verify first which Django versions you need are supported, and remember to call `PeriodicTasks.update_changed()` after any bulk update or the running beat process will not reload the schedule.

## FAQ

### What does django-celery-beat do differently from Celery's built-in beat?

It stores the periodic task schedule in the database rather than in a file, and lets you manage those tasks from the Django Admin. You run beat with the custom scheduler class, `--scheduler django_celery_beat.schedulers:DatabaseScheduler`, and both beat and a worker have to be running.

### Why do my schedule changes not take effect?

The beat process watches a counter in the `PeriodicTasks` table rather than the tasks themselves, so bulk updates that bypass the model layer do not signal a reload. Call `PeriodicTasks.update_changed()` after any bulk update.

### What happens if I change the Django TIME_ZONE setting?

Existing schedules keep the timezone they were created with. To apply the new setting, reset the last run time with `PeriodicTask.objects.all().update(last_run_at=None)` and then call `PeriodicTasks.update_changed()`, which resets state as if the tasks had never run, so tasks may fire immediately afterwards.

## Sources

- [celery/django-celery-beat on GitHub](https://github.com/celery/django-celery-beat)
- [Issues](https://github.com/celery/django-celery-beat/issues)
- [README](https://github.com/celery/django-celery-beat/blob/main/README.md)
- [Releases](https://github.com/celery/django-celery-beat/releases)

---

Hysen Labs editorial analysis, written from the project's own repository and release notes. Cite the canonical page: https://hysenlabs.com/projects/celery-django-celery-beat
