Skip to main content

django-logic

CI Coverage Status License

Django Logic is a workflow library for Django. You declare the states of a model, the transitions between them, and the code each transition runs. The declaration lives in one place, away from views, models and forms.

Work that is slow, external or retriable is a background transition. django-logic saves it as a database row. A worker process claims the row and runs it.

Requirements

  • Python 3.11 or later.
  • Django 4.2 or later. CI tests 4.2, 5.1, 5.2 and 6.0. Django 5.0 is not supported.
  • PostgreSQL for background transitions. The worker claims a row with SELECT FOR UPDATE SKIP LOCKED, and SQLite has no row locks.
  • A cross-process default cache. The web processes and the worker processes share the state lock through it. django-logic locks through Django's cache API and imports no backend, so django.core.cache.backends.redis.RedisCache is enough. Boot refuses a per-process cache (locmem, dummy) when DEBUG=False.

Install

pip install django-logic

Add [redis] if your settings name django_redis.cache.RedisCache. That extra installs the third-party backend for you.

Add one entry to INSTALLED_APPS and create the table:

INSTALLED_APPS = [
    ...,
    'django_logic.background',
]

That one entry is the whole library. It owns the TransitionMessage table and its migrations, the dl_worker and dl_transitions commands, and every system check. import django_logic works as normal — a Python package needs no INSTALLED_APPS entry to be importable.

Upgrading from a release that asked for a second entry, 'django_logic'? Delete that line whenever you like. Keeping it is supported and changes nothing: it is a real app and both boot hooks are idempotent.

Running no background transitions at all? Install 'django_logic' alone instead — also one entry. That shape has no table, no worker and no boot check on the database or the cache, so it runs on SQLite with Django's default cache. manage.py check names the missing app the moment you bind a BackgroundTransition.

python manage.py migrate

Point the default cache at a backend the web processes and the workers share:

CACHES = {
    'default': {
        'BACKEND': 'django.core.cache.backends.redis.RedisCache',
        'LOCATION': os.environ['REDIS_URL'],
    }
}

Every DJANGO_LOGIC key has a default, so you need no other configuration to start.

Declare a process

A process lists the transitions of one state field. A transition names the states it starts from, the state it ends in, and the functions it runs.

# models.py
from django.db import models


class Order(models.Model):
    STATUS_CHOICES = [
        ('draft', 'Draft'),
        ('approved', 'Approved'),
        ('fulfilled', 'Fulfilled'),
        ('fulfilment_failed', 'Fulfilment failed'),
        ('cancelled', 'Cancelled'),
    ]
    status = models.CharField(max_length=32, choices=STATUS_CHOICES, default='draft')
# process.py
from django_logic import Process, Transition


def has_stock(instance, **kwargs):
    return all(item.product.stock >= item.quantity for item in instance.items.all())


def is_staff_member(instance, user, **kwargs):
    return user.is_staff


def reserve_stock(instance, **kwargs):
    for item in instance.items.all():
        item.product.stock -= item.quantity
        item.product.save()


def send_approval_email(instance, **kwargs):
    ...


class OrderProcess(Process):
    process_name = 'process'
    transitions = [
        Transition(
            action_name='approve',
            sources=['draft'],
            target='approved',
            conditions=[has_stock],
            permissions=[is_staff_member],
            side_effects=[reserve_stock],
            callbacks=[send_approval_email],
        ),
        Transition(
            action_name='cancel',
            sources=['draft', 'approved'],
            target='cancelled',
        ),
    ]

Each declaration slot has one job:

  • conditions — functions that answer True or False. Every one must answer True, or the transition is not available.
  • permissions — functions that answer whether this user may run the transition. They receive user.
  • side_effects — the work of the transition. It runs before the object reaches the target state. A failure stops the state change, and django-logic writes failed_state when you declare one.
  • callbacks — functions that run after the object reaches the target state. They are best-effort: django-logic swallows what they raise.
  • failure_callbacks — functions that run after a side-effect fails. They receive exception=. Put cleanup and compensation here.

Declare a Transition with no target for work that needs conditions, permissions and side-effects but writes no state on success. It follows the same rules as every transition: it takes the state lock, it is refused while a background transition is uncompleted, and it runs next_transition. A side-effect that must not obey those rules is not a transition — write it as a plain method on the model.

Bind the model to the process

Bind in your app's AppConfig.ready(). This is the one supported place.

# apps.py
from django.apps import AppConfig
from django_logic import ProcessManager


class ShopConfig(AppConfig):
    name = 'shop'

    def ready(self):
        from .models import Order
        from .process import OrderProcess
        ProcessManager.bind_model_process(Order, OrderProcess, state_field='status')

Import the model and the process inside ready(). A process references its model, and so do its condition, permission and side-effect functions. Binding at module import time therefore builds the import cycle models.py → process.py → actions.py → models.py. Django loads every app's models before it runs any ready(), so binding here cannot build that cycle, and your action modules import the model at the top level like normal code.

List the app in INSTALLED_APPS, or Django never runs ready().

Run a transition

order = Order.objects.get(pk=pk)
order.process.approve(user=request.user)
  • The accessor is the process class's process_name. It is process by default.
  • Pass user= in a request handler. A call without user= is a system call, and it skips every permission check.
  • order.process.get_available_actions(user=request.user) lists what this user may run right now.
  • A refused transition raises TransitionNotAllowed from django_logic.exceptions. Its subclass TransitionTemporarilyUnavailable means the instance is busy, so the caller may retry shortly. Catch the subclass first.

Background transitions

BackgroundTransition runs its side-effects on a worker process, and retries them. Declared with no target, it changes no state on success — same durability, same rules. Import it from django_logic.background.

# process.py
from django_logic import Process, Transition
from django_logic.background import BackgroundTransition


class OrderProcess(Process):
    transitions = [
        Transition(action_name='approve', sources=['draft'], target='approved'),
        BackgroundTransition(
            action_name='fulfil',
            sources=['approved'],
            target='fulfilled',
            in_progress_state='fulfilling',
            failed_state='fulfilment_failed',
            queue='django_logic.critical',
            timeout=600,
            side_effects=[book_courier, print_labels],
            callbacks=[send_tracking_email],
        ),
    ]
# views.py — returns as soon as django-logic saves the row.
order.process.fulfil(user=request.user)

The call writes in_progress_state and one TransitionMessage row in a single transaction, then returns. A worker claims that row, runs the side-effects and writes the target state, all in one atomic block. A failed attempt rolls back its own database writes and becomes claimable again after TRANSITION_MESSAGE_RETRY_MINUTES. After TRANSITION_MESSAGE_MAX_ERRORS attempts django-logic writes failed_state, runs failure_callbacks and completes the row.

  • Side-effects must be idempotent against external systems. A retry runs them again from the start, so a payment or an email can happen twice.
  • queue= is optional. A transition without it runs on DJANGO_LOGIC['DEFAULT_QUEUE'], which is django_logic. Name a queue per service level and give it its own worker.
  • timeout= is optional. The worker kills an attempt that runs past it and records one error on the row.
  • Raise PermanentFailure from django_logic.background when a retry cannot help. The worker then takes the failure path on the first attempt. For an exception type you do not own, list it in no_retry_on=(CustomsRefusal,).
  • While a row is uncompleted, a second background transition on the same instance and process raises AlreadyInProgress, and a synchronous transition on it raises TransitionTemporarilyUnavailable. Start follow-up work from a callback, which runs after django-logic completes the row.

docs/design/PULL_WORKERS.md explains how a worker claims a row and what happens when one dies.

Run the workers

One dl_worker process serves one group of queues.

python manage.py dl_worker --queues django_logic.critical,django_logic.fast
python manage.py dl_worker --queues django_logic.slow --concurrency=4

--concurrency=N says how many attempts one worker runs at a time. The default is 1. Each attempt runs in its own forked process and holds its own database connection. Read "Sizing a deployment" in docs/design/PULL_WORKERS.md for the memory and connection budget.

A worker that dies releases its row lock with its database connection, and the next claim takes the row.

See what is not moving

python manage.py dl_transitions
python manage.py dl_transitions --send 1234

dl_transitions lists every uncompleted background transition and says why each one is not moving: it has spent every attempt, it waits out the retry pause, a worker runs it now, or no worker serves its queue. --queues narrows the list.

--send <pk> clears the retry wait on one row and wakes the workers, so the next claim takes it. The command runs no side-effects itself.

Two safety nets

The worker loop runs two safety nets once a minute. Nothing else needs a schedule.

  • detect_stuck_transitions finalizes a row that has spent every attempt: it writes failed_state, runs failure_callbacks and completes the row. It also reports a row that waited past the retry window with no attempt, which means no worker serves that row's queue.
  • cleanup_completed_transitions deletes completed rows older than TRANSITION_MESSAGE_CLEANUP_DAYS. It keeps the newest failed row per instance and process, because that row is the only explanation for an instance parked in its failed_state.

Alert when the worker processes stop. The safety nets stop with them.

Test your process

django_logic.testing gives you ProcessScenario, a test base class that runs a whole workflow inline, background transitions included. A test reads like the business process.

from django_logic.testing import ProcessScenario


class TestOrderFulfilment(ProcessScenario):
    process_class = OrderProcess
    model = Order
    state_field = 'status'

    def test_happy_path(self):
        order = self.create_instance(status='approved')
        self.background_transition(order, 'fulfil')
        self.assert_state(order, 'fulfilled')
        self.assert_side_effects_ran(['book_courier'])

Sync execution runs the worker path inline, for tests only. A test settings module calls django_logic.conf.enable_sync() and sets DJANGO_LOGIC['BACKGROUND_EXECUTION'] = 'sync'. Boot refuses that value anywhere else. docs/TESTING_GUIDE.md has the setup and the full scenario catalog.

More documentation

Contributing

Pull requests are welcome. Open an issue first for a major change.

pip install -e '.[dev]'
python tests/manage.py test          # SQLite suite

make build                           # or run the same suite in Docker
make test
make test-one t=tests.test_transition

Add a test for every change, and update the documentation the change touches.

Report a bug in the issue tracker.

License

MIT

Metadata

Release files for django-logic 1.0.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-logic 1.0.0
File Size Uploaded
django_logic-1.0.0.tar.gz 155.5 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for django-logic 1.0.0
File Interpreter ABI Platform
django_logic-1.0.0-py3-none-any.whl Python 3 none any Details

Total release size: 273.4 kB

Release files / django_logic-1.0.0.tar.gz

Download URL django_logic-1.0.0.tar.gz
Size 155.5 kB
Tags Source
SHA-256 checksum
How to use checksums
94c519aa52d6321f82dbc7552b4cbfa4fb5fe19c40dfcb72a46b709189aad752
BLAKE2b-256 checksum
How to use checksums
24b2a94192fd9819f29759f8e786a030004e6cafc4ac385d6736ee9499f083d7
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 Aug 25, 2026.

Transparency log

Release files / django_logic-1.0.0-py3-none-any.whl

Download URL django_logic-1.0.0-py3-none-any.whl
Size 117.8 kB
Tags Python 3
SHA-256 checksum
How to use checksums
4d08e346a6fa7c3d500b2937b1a8a2db57a6f05938d46259fd6a8ad3e5cd690a
BLAKE2b-256 checksum
How to use checksums
5523d28d05912fa61479df81561c63d028364b718736d839063316c3813f15d7
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 Aug 25, 2026.

Transparency log

Release history Release notifications | RSS feed

2.3.1

2 release files

2.3.0

2 release files

2.2.1

2 release files

2.2.0

2 release files

2.1.0

2 release files

2.0.0

2 release files

1.2.0

2 release files

1.1.0

2 release files

This release

1.0.0 This release

2 release files

0.17.2

2 release files

0.17.1

2 release files

0.17.0

2 release files

0.16.0

2 release files

0.14.1

2 release files

0.14.0

2 release files

0.13.1

2 release files

0.12.0

2 release files

0.11.0

2 release files

0.10.0

2 release files

0.9.1

2 release files

0.9.0

2 release files

0.8.0

2 release files

0.7.0

2 release files

0.6.0

2 release files

0.5.1

2 release files

0.5.0

2 release files

0.4.1

2 release files

0.4.0

2 release files

0.1.6

2 release files

0.1.5

2 release files

0.1.4

2 release files

0.1.3

2 release files

0.1.2

2 release files

0.1.1

2 release files

0.1.0

3 release files

0.0.13

2 release files

0.0.12

2 release files

0.0.11

2 release files

0.0.10

2 release files

0.0.9

2 release files

0.0.8

2 release files

0.0.7

2 release files

0.0.6

2 release files

0.0.5

2 release files

0.0.4

2 release files

0.0.3

2 release files

0.0.2

2 release files

0.0.1

2 release files

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