Django Model State Fields

FSMField, FSMIntegerField and FSMKeyField store state in a Django model column and guard transition methods on that model. They are part of django-fsm; a separate flow class is not required.

from django.db import models
from django_fsm import FSMField, transition

class Job(models.Model):
    status = FSMField(default='new')

    @transition(field=status, source='new', target='running')
    def start(self):
        pass

You can also call status.transition(...) directly on the field:

class Job(models.Model):
    status = FSMField(default='new')

    @status.transition(source='new', target='running')
    def start(self):
        pass

FSMField is a CharField with max_length=50 by default. FSMIntegerField provides the same API with an integer column.

The standalone transition(field=...) decorator accepts either a field object or its name (field='status'). Names are resolved on the model, so decorators in ordinary Python mixins also work. Use source='*' (also the default) for any source state, source='+' for any state except the target, and target=None to leave state unchanged. An explicitly registered source takes precedence over a wildcard.

With no permission argument, the standalone decorator’s permission check allows any user. A string permission such as permission='myapp.publish_job' checks object permission first, then global permission through Django’s user.has_perm(). Source and condition checks still apply to direct calls. User permissions require an explicit has_transition_perm() check or a UI integration.

can_proceed(job.start) checks the source state and conditions; can_proceed(job.start, check_conditions=False) checks only the source. has_transition_perm(job.start, user) checks the source, conditions and permission together. Both helpers can be imported from django_fsm.

The field adds three methods to the model (replace status with your field name):

  • get_all_status_transitions() lists all declared transitions.
  • get_available_status_transitions() checks the current source and conditions.
  • get_available_user_status_transitions(user) also checks permissions.

Each result exposes name, method, source, target, on_error and custom. Methods belonging to another FSM field are excluded.

The direct status.transition(...) form follows the native State API: use State.ANY for a wildcard, omit target to leave state unchanged, and set permission explicitly to allow access through permission checks.

The value lives in the model’s own column – no setter/getter pair to write, Job.objects.filter(status='running') and serialization work as they would for any CharField. Deferred fields loaded with only() or defer() fetch their state from the database when first read, including when a transition needs the current state. The column type is unchanged; check any generated AlterField migration before applying it.

RETURN_VALUE and GET_STATE can be imported directly from django_fsm; they are aliases of State.RETURN_VALUE and State.GET_STATE. They validate resolved dynamic targets and raise InvalidTargetState for an invalid result.

Transition timing

An ordinary target is applied before calling the method, so self.status inside the method contains the target state. If the method raises without an on_error target, django-fsm restores the in-memory source state and proxy class. This does not roll back database writes made by the method. Use a database transaction where needed.

GET_STATE also computes and applies its target before the method runs; RETURN_VALUE applies the target after the method returns.

Native transition metadata uses State.ANY and DEFAULT sentinels. The model helpers get_all_*_transitions and get_available_*_transitions expose the '*' and None values. Private _django_fsm metadata is not recreated; integrations that use it need separate changes.

Signals and error targets

Model-field transitions emit pre_transition and post_transition from django_fsm.signals. Plain State transitions do not emit these signals.

pre_transition runs after source and condition checks, before the target is computed or applied. The instance still has its source state. post_transition runs after a successful method and target validation. Both signals receive instance, name, field, source, target, method_args and method_kwargs. The sender is the instance’s source class, even if a transition switches its proxy class. For dynamic targets, the pre signal receives the declared target object, and the post signal receives the resolved value. A transition without a target reports None.

Use on_error on either decorator form to select a state when the method or dynamic target resolution raises an exception:

@transition(field=status, source='new', target='done', on_error='failed')
def finish(self):
    perform_work()

The adapter applies the error state, emits post_transition with that target and the original exception, then re-raises it. Without an error target, it restores the source state without emitting the post signal. State changes are in memory until the model is saved. Zero is a valid error target for FSMIntegerField.

Rejected sources or conditions do not emit signals or use on_error. Interrupts such as KeyboardInterrupt restore the source state without using on_error. Receiver exceptions propagate: a failing pre receiver prevents the method from running, and a failing post receiver leaves the completed target state in memory.

Foreign-key states

FSMKeyField stores a state key in a Django foreign-key column:

from django_fsm import FSMKeyField, transition

class Job(models.Model):
    status = FSMKeyField('myapp.JobState', on_delete=models.PROTECT)

    @transition(field=status, source='new', target='done')
    def finish(self):
        pass

Both job.status and job.status_id expose the raw key, rather than a related model object. Declare sources, targets, defaults and error targets using those keys. to_field is supported for references to a unique non-primary-key column. Queryset joins such as Job.objects.filter(status__label='Done') and model forms use the normal Django relationship. There is no forward object accessor to prefetch; retrieve a related state object with its model’s manager when needed.

protected=True protects assignment through both attribute names. Transitions can still change the key. Deferred state reads load the key from the database.

State-specific proxy classes

Use state_choices instead of choices to map each state to a proxy model. Each entry contains the value, display label and proxy model name:

class Job(models.Model):
    status = FSMField(
        default='new',
        state_choices=[
            ('new', 'New', 'NewJob'),
            ('done', 'Done', 'myapp.DoneJob'),
        ],
    )

class NewJob(Job):
    class Meta:
        proxy = True

class DoneJob(Job):
    class Meta:
        proxy = True

Names without an app label resolve within the field’s app. Each referenced model must be a proxy of the same concrete model. Assignment, database loading and transitions select the matching class. An ordinary transition method sees its target proxy because the target is applied before the body runs. For a deferred state field, switching occurs when the state is first loaded. Proxy mappings are preserved by field cloning and migrations.

Concurrent transitions

Add ConcurrentTransitionMixin to a model to reject saves when an observed FSM state has changed in the database:

from django.db import transaction
from django_fsm import ConcurrentTransitionMixin, FSMField

class Job(ConcurrentTransitionMixin, models.Model):
    status = FSMField(default='new')

    @status.transition(source='new', target='running')
    def start(self):
        pass

with transaction.atomic():
    job = Job.objects.get(pk=job_id)
    job.start()
    job.save()

The mixin includes the observed state values in the SQL UPDATE predicate. If the row still exists but its state no longer matches, save() raises ConcurrentTransition instead of overwriting it. No version column is needed. Char, integer and foreign-key state fields are supported, including multiple fields, proxy models and multi-table inheritance.

Successful saves update the baseline only for saved state columns. save(update_fields=['text']) checks the states but does not accept an unsaved transition as a new baseline. refresh_from_db(fields=...) only resets the baseline for the refreshed fields. With FSMModelMixin, the default refresh still skips protected fields; retrieve a fresh instance to reload their persisted state after a conflict.

Deferred states are not fetched during construction. Their baseline is recorded when they are first read or before a direct assignment. If a save checks a state that has never been loaded, it reads that column immediately before the update. It cannot detect changes that happened before that first observation.

Use a transaction around the transition and save to roll back other database changes on conflict, and catch ConcurrentTransition outside that block. The mixin does not undo external side effects or restore Python attributes after a rollback. It compares state values, not the full model: edits to other columns, or a state that changed and then returned to the same value, are not versioned. QuerySet.update(), bulk writes and raw SQL bypass these checks.

Two Opt-in Guarantees

protected and enforce_initial both default to False, allowing direct writes and unrestricted initial values unless enabled. Both options are preserved when Django clones the field or reconstructs it from migrations.

protected=True

Blocks direct assignment (job.status = 'running') after the instance’s first value is set, the same way a plain State field already blocks flow.state_field = x unconditionally. Transitions are unaffected – they write through State.set(), not attribute assignment.

class Job(models.Model):
    status = FSMField(default='new', protected=True)

    @status.transition(source='new', target='running')
    def start(self):
        pass

job = Job.objects.create()
job.status = 'running'  # AttributeError
job.start()              # fine

Known limitation: protected=True also blocks a plain instance.refresh_from_db(), since that reassigns the field through the same guarded path and the slot is already populated by then. Use FSMModelMixin to refresh other fields while leaving protected state unchanged:

from django_fsm import FSMField, FSMModelMixin

class Job(FSMModelMixin, models.Model):
    status = FSMField(default='new', protected=True)

With this mixin, refresh_from_db() skips protected and deferred fields by default. It refreshes other loaded fields, leaving protected state in memory unchanged. An explicit fields=... list is passed through to Django, so explicitly refreshing an already loaded protected field still raises AttributeError.

enforce_initial=True

By default, nothing stops Job.objects.create(status='running') from inserting a row that never went through start(). With it, creating a row (an INSERT, not an UPDATE) at any value other than the field’s default raises NonInitialStateOnCreate, a TransitionNotAllowed subclass – before the row is written.

class Job(models.Model):
    status = FSMField(default='new', enforce_initial=True)

    @status.transition(source='new', target='running')
    def start(self):
        pass

Job.objects.create()                    # fine -- status='new'
Job.objects.create(status='running')    # NonInitialStateOnCreate

The check runs from the field’s pre_save, which Django calls for .save(), .create(), bulk_create(), and rows inserted through get_or_create()/update_or_create(). Raw SQL and fixture loading skip pre_save and are not guarded. Queryset updates also bypass the check; this option only validates inserted rows.