Django friendly finite state machine support
Django FSM-2 adds simple, declarative state management to Django models.
Introduction
FSM really helps to structure the code, and centralize the lifecycle of your Models.
Instead of adding a CharField field to a django model and manage its values by hand everywhere, FSMFields offer the ability to declare your transitions once with the decorator. These methods could contain side-effects, permissions, or logic to make the lifecycle management easier.
Nice introduction is available here: https://gist.github.com/Nagyman/9502133
Quick start
from django.db import models
import django_fsm as fsm
class BlogPost(fsm.FSMModelMixin, models.Model):
state = fsm.FSMField(default="new")
@fsm.transition(field=state, source="new", target="published")
def publish(self, **kwargs):
pass
import django_fsm as fsm
post = BlogPost.objects.get(pk=1)
if fsm.can_proceed(post.publish):
post.publish()
post.save()
Installation
Install the package:
uv pip install django-fsm-2
Or install from git:
uv pip install -e git://github.com/django-commons/django-fsm-2.git#egg=django-fsm
Add django_fsm to your Django apps (required to graph transitions or use Admin integration):
INSTALLED_APPS = (
...,
"django_fsm",
...,
)
Django FSM-2 is a drop-in replacement. Update your dependency from
django-fsm to django-fsm-2 and keep your existing code.
uv pip install django-fsm-2
Usage
Core ideas
- Store a state in an
FSMField(orFSMIntegerField/FSMKeyField). - Declare transitions once with the
@fsm.transitiondecorator. - Transition methods can contain business logic and side effects.
- The in-memory state changes on success;
save()persists it.
Adding an FSM field
import django_fsm as fsm
class BlogPost(fsm.FSMModelMixin, models.Model):
state = fsm.FSMField(default="new")
Declaring a transition
import django_fsm as fsm
@fsm.transition(field=state, source="new", target="published")
def publish(self, **kwargs):
"""
This function may contain side effects,
like updating caches, notifying users, etc.
The return value will be discarded.
"""
The field parameter accepts a string attribute name or a field instance.
If calling publish() succeeds without raising an exception, the state
changes in memory. You must call save() to persist it.
import django_fsm as fsm
def publish_view(request, post_id, **kwargs):
post = get_object_or_404(BlogPost, pk=post_id)
if not fsm.can_proceed(post.publish):
raise PermissionDenied
post.publish()
post.save()
return redirect("/")
Preconditions (conditions)
Use conditions to restrict transitions. Each function receives the
instance and must return truthy/falsey. The functions should not have
side effects.
import django_fsm as fsm
def can_publish(instance):
# No publishing after 17 hours
return datetime.datetime.now().hour <= 17
class XXX(fsm.FSMModelMixin, models.Model):
@fsm.transition(field=state, source="new", target="published", conditions=[can_publish])
def publish(self, **kwargs):
pass
You can also use model methods:
import django_fsm as fsm
class XXX(fsm.FSMModelMixin, models.Model):
def can_destroy(self):
return self.is_under_investigation()
@fsm.transition(field=state, source="*", target="destroyed", conditions=[can_destroy])
def destroy(self, **kwargs):
pass
When a transition is blocked by an unmet condition, the raised
TransitionConditionsUnmet exception includes the failing callable in its
failed_condition attribute for programmatic handling:
from django_fsm import TransitionConditionsUnmet
try:
instance.publish()
except TransitionConditionsUnmet as e:
print(e.failed_condition) # e.g. <function can_publish at 0x...>
Protected state fields
Use protected=True to prevent direct assignment. Only transitions may
change the state.
Because refresh_from_db assigns to the field, protected fields raise there
as well unless you use FSMModelMixin. Use FSMModelMixin by default to
allow refresh without enabling arbitrary writes elsewhere.
import django_fsm as fsm
class BlogPost(fsm.FSMModelMixin, models.Model):
state = fsm.FSMField(default="new", protected=True)
model = BlogPost()
model.state = "invalid" # Raises AttributeError
model.refresh_from_db() # Works
Source and target states
source accepts a list of states, a single state, or a django_fsm.State
implementation.
source="*"allows switching totargetfrom any state (accessible as ANY_STATE constant).source="+"allows switching totargetfrom any state excepttarget(accessible as ANY_OTHER_STATE constant).
target can be a specific state or a django_fsm.State implementation.
import django_fsm as fsm
@fsm.transition(
field=state,
source="*",
target=fsm.RETURN_VALUE("for_moderators", "published"),
)
def publish(self, is_public=False, **kwargs):
return "for_moderators" if is_public else "published"
@fsm.transition(
field=state,
source="for_moderators",
target=fsm.GET_STATE(
lambda self, allowed: "published" if allowed else "rejected",
states=["published", "rejected"],
),
)
def moderate(self, allowed, **kwargs):
pass
@fsm.transition(
field=state,
source="for_moderators",
target=fsm.GET_STATE(
lambda self, **kwargs: "published" if kwargs.get("allowed", True) else "rejected",
states=["published", "rejected"],
),
)
def moderate(self, allowed=True, **kwargs):
pass
Custom transition metadata
Use custom to attach arbitrary data to a transition.
@fsm.transition(
field=state,
source="*",
target="onhold",
custom=dict(verbose="Hold for legal reasons"),
)
def legal_hold(self, **kwargs):
pass
Error target state
If a transition method raises an exception, you can specify an on_error
state.
@fsm.transition(field=state, source="new", target="published", on_error="failed")
def publish(self, **kwargs):
"""
Some exception could happen here
"""
Permissions
Attach permissions to transitions with the permission argument. It
accepts a permission string or a callable that receives (instance, user).
@fsm.transition(
field=state,
source="*",
target="published",
permission=lambda instance, user: not user.has_perm("myapp.can_make_mistakes"),
)
def publish(self, **kwargs):
pass
@fsm.transition(
field=state,
source="*",
target="removed",
permission="myapp.can_remove_post",
)
def remove(self, **kwargs):
pass
Check permission with has_transition_perm:
import django_fsm as fsm
def publish_view(request, post_id):
post = get_object_or_404(BlogPost, pk=post_id)
if not fsm.has_transition_perm(post.publish, request.user):
raise PermissionDenied
post.publish()
post.save()
return redirect("/")
Model helpers
Considering a model with a state field called "FIELD"
get_all_FIELD_transitionsenumerates all declared transitions.get_available_FIELD_transitionsreturns transitions available in the current state.get_available_user_FIELD_transitionsreturns transitions available in the current state for a given user.
Example: If your state field is called status
my_model_instance.get_all_status_transitions()
my_model_instance.get_available_status_transitions()
my_model_instance.get_available_user_status_transitions()
FSMKeyField (foreign key support)
Use FSMKeyField to store state values in a table and maintain FK
integrity.
import django_fsm as fsm
class DbState(fsm.FSMModelMixin, models.Model):
id = models.CharField(primary_key=True)
label = models.CharField()
def __str__(self):
return self.label
class BlogPost(fsm.FSMModelMixin, models.Model):
state = fsm.FSMKeyField(DbState, default="new")
@fsm.transition(field=state, source="new", target="published")
def publish(self, **kwargs):
pass
In your fixtures/initial_data.json:
[
{
"pk": "_NEW_",
"model": "myapp.dbstate",
"fields": {
"label": "New"
}
},
{
"pk": "_PUBLISHED_",
"model": "myapp.dbstate",
"fields": {
"label": "Published"
}
}
]
Note: source and target use the PK values of the DbState model as
names, even if the field is accessed without the _id postfix.
FSMIntegerField (enum-style states)
import django_fsm as fsm
class BlogPostStateChoices(models.IntegerChoices):
NEW = 10, "New"
PUBLISHED = 20, "Published"
HIDDEN = 30, "Hidden"
class BlogPostWithIntegerField(fsm.FSMModelMixin, models.Model):
state = fsm.FSMIntegerField(default=BlogPostStateChoices.NEW)
@fsm.transition(
field=state,
source=BlogPostStateChoices.NEW,
target=BlogPostStateChoices.PUBLISHED,
)
def publish(self, **kwargs):
pass
Signals
django_fsm.signals.pre_transition and django_fsm.signals.post_transition
fire before and after an allowed transition. No signals fire for invalid
transitions.
Arguments sent with these signals:
senderThe model class.instanceThe actual instance being processed.nameTransition name.sourceSource model state.targetTarget model state.
Optimistic locking
Use ConcurrentTransitionMixin to avoid concurrent state changes. If the
state changed in the database, django_fsm.ConcurrentTransition is raised
on save().
import django_fsm as fsm
class BlogPost(fsm.ConcurrentTransitionMixin, models.Model):
state = fsm.FSMField(default="new")
For guaranteed protection against race conditions caused by concurrently executed transitions, make sure:
- Your transitions do not have side effects except for database changes.
- You always call
save()within adjango.db.transaction.atomic()block.
Following these recommendations, ConcurrentTransitionMixin will cause a
rollback of all changes executed in an inconsistent state.
Admin Integration
NB: If you're migrating from django-fsm-admin (or any alternative), make sure it's not installed anymore to avoid installing the old django-fsm.
Update import path:
- from django_fsm_admin.mixins import FSMTransitionMixin
+ from django_fsm.admin import FSMAdminMixin
- In your admin.py file, use FSMAdminMixin to add behaviour to your ModelAdmin. FSMAdminMixin should be before ModelAdmin, the order is important.
from django_fsm.admin import FSMAdminMixin
@admin.register(AdminBlogPost)
class MyAdmin(FSMAdminMixin, admin.ModelAdmin):
# Declare the fsm fields you want to manage
fsm_fields = ["my_fsm_field"]
...
- You can customize the buttons by adding
labelandhelp_textto thecustomattribute of the transition decorator
@fsm.transition(
field="state",
source=["startstate"],
target="finalstate",
custom={
"label": "My awesome transition", # this
"help_text": "Rename blog post", # and this
},
)
def do_something(self, **kwargs): ...
or by overriding some methods in FSMAdminMixin
from django_fsm.admin import FSMAdminMixin
@admin.register(AdminBlogPost)
class MyAdmin(FSMAdminMixin, admin.ModelAdmin):
...
def get_fsm_label(self, transition): # this method
if transition.name == "do_something":
return "My awesome transition"
return super().get_fsm_label(transition)
def get_help_text(self, transition): # and this method
if transition.name == "do_something":
return "Rename blog post"
return super().get_help_text(transition)
-
For forms in the admin transition flow, see the Custom Forms section below.
-
Hiding a transition is possible by adding
custom={"admin": False}to the transition decorator:
@fsm.transition(
field='state',
source=['startstate'],
target='finalstate',
custom={
"admin": False, # this
},
)
def do_something(self, **kwargs):
# will not add a button "Do Something" to your admin model interface
or from the admin:
from django_fsm.admin import FSMAdminMixin
@admin.register(AdminBlogPost)
class MyAdmin(FSMAdminMixin, admin.ModelAdmin):
...
def is_fsm_transition_visible(self, transition: fsm.Transition) -> bool:
if transition.name == "do_something":
return False
return super().is_fsm_transition_visible(transition)
NB: By adding FSM_ADMIN_FORCE_PERMIT = True to your configuration settings (or fsm_default_disallow_transition = False to your admin), the above restriction becomes the default.
Then one must explicitly allow that a transition method shows up in the admin interface using custom={"admin": True}
from django_fsm.admin import FSMAdminMixin
@admin.register(AdminBlogPost)
class MyAdmin(FSMAdminMixin, admin.ModelAdmin):
fsm_default_disallow_transition = False
...
Custom Forms
You can attach a custom form to a transition so the admin prompts for input
before the transition runs. Add a form entry to custom on the transition,
or define an admin-level mapping via fsm_forms. Both accept a forms.Form/
forms.ModelForm class or a dotted import path.
from django import forms
import django_fsm as fsm
class RenameForm(forms.Form):
new_title = forms.CharField(max_length=255)
# it's also possible to declare fsm log description
description = forms.CharField(max_length=255)
class BlogPost(fsm.FSMModelMixin, models.Model):
title = models.CharField(max_length=255)
state = fsm.FSMField(default="created")
@fsm.transition(
field=state,
source="*",
target="created",
custom={
"label": "Rename",
"help_text": "Rename blog post",
"form": "path.to.RenameForm",
},
)
def rename(self, new_title, **kwargs):
self.title = new_title
You can also define forms directly on your ModelAdmin without touching the
transition definition:
from django_fsm.admin import FSMAdminMixin
from .admin_forms import RenameForm
@admin.register(AdminBlogPost)
class MyAdmin(FSMAdminMixin, admin.ModelAdmin):
fsm_fields = ["state"]
fsm_forms = {
"rename": "path.to.RenameForm", # use import path
"rename": RenameForm, # or FormClass
}
Behavior details:
- When
formis set, the transition button redirects to a form view instead of executing immediately. - If both are defined,
fsm_formson the admin takes precedence overcustom["form"]on the transition. - On submit,
cleaned_datais passed to the transition method as keyword arguments and the object is saved. RenameFormreceives the current instance automatically.- You can override the transition form template by setting
fsm_transition_form_templateon yourModelAdmin(or override globallytemplates/django_fsm/fsm_admin_transition_form.html).
Unfold support
If you use Django Unfold, this package provide a contrib that contains templates tailored to the Unfold admin UI.
INSTALLED_APPS = (
...,
"unfold",
"django_fsm.contrib.unfold",
...,
)
Drawing transitions
Render a graphical overview of your model transitions.
- Install graphviz support:
uv pip install django-fsm-2[graphviz]
or
uv pip install "graphviz>=0.4"
- Ensure
django_fsmis inINSTALLED_APPS:
INSTALLED_APPS = (
...,
"django_fsm",
...,
)
- Run the management command:
# Create a dot file
./manage.py graph_transitions > transitions.dot
# Create a PNG image file for a specific model
./manage.py graph_transitions -o blog_transitions.png myapp.Blog
# Exclude some transitions
./manage.py graph_transitions -e transition_1,transition_2 myapp.Blog
Styling the graph
graph_transitions's Command is designed to be subclassed, so you can
color or style the exported diagram by overriding a couple of hook methods.
Subclass the command from an app listed before django_fsm in
INSTALLED_APPS:
# myapp/management/commands/graph_transitions.py
from django_fsm.management.commands.graph_transitions import Command as BaseCommand
STATE_COLORS = {"approved": "green", "rejected": "red"}
class Command(BaseCommand):
def get_transition_edge_attrs(self, transition):
if transition.name == "approve":
return {"color": "green", "penwidth": "2"}
if color := transition.custom.get("graph_color"):
return {"color": color, "penwidth": "2"}
return super().get_transition_edge_attrs(transition)
def get_state_node_attrs(self, field, state, label):
if color := STATE_COLORS.get(state):
return {"style": "filled", "fillcolor": color}
return super().get_state_node_attrs(field, state, label)
Extensions
Transition logging support could be achieved with help of django-fsm-log package : https://github.com/gizmag/django-fsm-log
Contributing
We welcome contributions. See CONTRIBUTING.md for detailed setup
instructions.
Quick Development Setup
# Clone and setup
git clone https://github.com/django-commons/django-fsm-2.git
cd django-fsm
uv sync
# Run tests
uv run pytest -v
# or
uv run tox
# Run linting
uv run ruff format .
uv run ruff check .
Metadata
Release files for django-fsm-2 4.3.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | Provenance | Actions |
|---|---|---|---|---|
| django_fsm_2-4.3.0.tar.gz | 24.7 kB | With provenance | Details for django_fsm_2-4.3.0.tar.gz |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Actions Reset |
|---|---|---|---|---|
| django_fsm_2-4.3.0-py3-none-any.whl | Python 3 | none | any | Details for django_fsm_2-4.3.0-py3-none-any.whl |
Total release size: 53.4 kB
Release files / django_fsm_2-4.3.0.tar.gz
| Download URL | django_fsm_2-4.3.0.tar.gz |
|---|---|
| Size | 24.7 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
1e47a4b29904c201df16a68208f8e337db826df1488670447d5d0ade87ae7d7f
|
|
BLAKE2b-256 checksum How to use checksums |
77ad00148dce898f9d79c1c97687dc2800e9db737234c3fd269676b10f29ba3a
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.16
|
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 Oct 28, 2026.
Transparency logRelease files / django_fsm_2-4.3.0-py3-none-any.whl
| Download URL | django_fsm_2-4.3.0-py3-none-any.whl |
|---|---|
| Size | 28.7 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
6f578e5dda9738418bef12b94d20bcbde4751f12fbf41f7c4bdf10bff5e616a7
|
|
BLAKE2b-256 checksum How to use checksums |
ea64724c7402174f928e3d797809c90c0f2daf2d92436c107d84845c36d16366
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.16
|
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 Oct 28, 2026.
Transparency logSecurity for django-fsm-2 4.3.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.
Attestation for django_fsm_2-4.3.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 Oct 28, 2026.
Transparency logA 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.