Skip to main content

Django friendly finite state machine support

CI tests codecov Documentation MIT License

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 (or FSMIntegerField/FSMKeyField).
  • Declare transitions once with the @fsm.transition decorator.
  • 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 to target from any state (accessible as ANY_STATE constant).
  • source="+" allows switching to target from any state except target(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_transitions enumerates all declared transitions.
  • get_available_FIELD_transitions returns transitions available in the current state.
  • get_available_user_FIELD_transitions returns 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:

  • sender The model class.
  • instance The actual instance being processed.
  • name Transition name.
  • source Source model state.
  • target Target 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 a django.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
  1. 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"]
    ...
  1. You can customize the buttons by adding label and help_text to the custom attribute 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)
  1. For forms in the admin transition flow, see the Custom Forms section below.

  2. 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 form is set, the transition button redirects to a form view instead of executing immediately.
  • If both are defined, fsm_forms on the admin takes precedence over custom["form"] on the transition.
  • On submit, cleaned_data is passed to the transition method as keyword arguments and the object is saved.
  • RenameForm receives the current instance automatically.
  • You can override the transition form template by setting fsm_transition_form_template on your ModelAdmin (or override globally templates/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.

  1. Install graphviz support:
uv pip install django-fsm-2[graphviz]

or

uv pip install "graphviz>=0.4"
  1. Ensure django_fsm is in INSTALLED_APPS:
INSTALLED_APPS = (
    ...,
    "django_fsm",
    ...,
)
  1. 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)

Source distribution for django-fsm-2 4.3.0
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)

Table of built distributions (wheels) for django-fsm-2 4.3.0
File Interpreter ABI Platform Actions
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 log

Release 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 log

Security 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.

Select from 2 files

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 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.

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