xstate-statemachine vs django-fsm
An honest comparison with django-fsm-2: hierarchy, parallel regions, durable timers, locking, admin, audit, XState JSON, a side-by-side order lifecycle, a migration recipe, and when to choose django-fsm instead.
django-fsm, maintained today as django-fsm-2, is the state machine most Django projects already use. It puts a state column on your model and turns model methods into transitions with a @transition decorator. It is small, well understood, and has admin buttons and signals. This page compares it with xstate-statemachine and is explicit about where django-fsm is the better choice.
The table is generated from docs/_data/comparisons.json. Every row carries a source note, and corrections are welcome as PRs against that file. It was checked against django-fsm-2 4.3.0, 2026-11-07; see django-fsm-2 for the current state.
✅ Django status. The [django] extra ships the model field, locking, signals, audit, permissions and the admin (#280–#282); [drf] and [channels] ship the REST and WebSocket surfaces (#283); xsm_migrate_fsm does the migration below (#310). See the Django integration page.
Feature table
| Capability | xstate-statemachine | django-fsm-2 | Source |
|---|---|---|---|
| Hierarchy (nested states) | Yes: compound states at any depth, XState v5 semantics | No: one flat state field per model | django-fsm-2 4.3.0: FSMField holds a single string or integer state; ours: xstate-statemachine docs: Persistence, Guarantees and the integration pages |
| Parallel regions | Yes: type: parallel regions with a done.state join |
No (use several FSM fields on one model) | django-fsm-2 4.3.0: one @transition graph per field; ours: xstate-statemachine docs: Persistence, Guarantees and the integration pages |
Timers (after) |
after in the chart; persisted as deadlines and fired by DueTimerScanner after a restart |
No: schedule them yourself (Celery, cron) | django-fsm-2 4.3.0: no timer API in django_fsm; ours: xstate-statemachine docs: Persistence, Guarantees and the integration pages |
| Invoked services | invoke services with onDone / onError, retried by RetryPolicy |
No: call services inside the transition method | django-fsm-2 4.3.0: @transition wraps a model method; ours: xstate-statemachine docs: Persistence, Guarantees and the integration pages |
| Actors / child machines | Spawned child machines (spawn, sendTo, sendParent) |
No | django-fsm-2 4.3.0: no child-machine concept; ours: xstate-statemachine docs: Persistence, Guarantees and the integration pages |
| Async | Two engines: asyncio Interpreter and threaded SyncInterpreter |
Sync only | django-fsm-2 4.3.0: transitions are plain model methods; ours: xstate-statemachine docs: Persistence, Guarantees and the integration pages |
| Locking / concurrent writers | OptimisticLock / PessimisticLock on every store; Django send() in transaction.atomic() + select_for_update() (or a version fence + send_with_retry); SQLAlchemy version_id_col |
ConcurrentTransitionMixin raises ConcurrentTransition on a stale write |
django-fsm-2 4.3.0: ConcurrentTransitionMixin in django_fsm/__init__.py; ours: xstate-statemachine docs: Persistence, Guarantees and the integration pages |
| Versioning / migration | machine_version in every snapshot; SnapshotMigrator upgrades old snapshots on load |
Plain Django migrations of the state column | django-fsm-2 4.3.0: state is an ordinary CharField / IntegerField; ours: xstate-statemachine docs: Persistence, Guarantees and the integration pages |
| Admin / UI | StatechartAdminMixin: permission-computed CSRF-protected transition buttons, confirm + reason, history inline, state filter, bulk actions; manage.py xsm_inspect / xsm_diagram |
Yes: django_fsm.admin.FSMAdminMixin transition buttons in the Django admin |
django-fsm-2 4.3.0: django_fsm.admin.FSMAdminMixin (the old fsm_admin.mixins import is a deprecated shim, check django_fsm.admin.W001); ours: xstate-statemachine docs: Persistence, Guarantees and the integration pages |
| REST / DRF | StatechartViewSetMixin generates one DRF @action per event (plus drf-spectacular schema); FastAPI router, Litestar controller, Flask/Quart blueprints, Starlette registry helpers |
DIY: call the transition from a DRF view | django-fsm-2 4.3.0: no REST helpers in the package; ours: xstate-statemachine docs: Persistence, Guarantees and the integration pages |
| Audit trail | Django TransitionLog rows written in the SAME transaction as the state change (plus pre_transition / post_transition signals); transition log for SQLite, JSON lines, SQLAlchemy |
pre_transition / post_transition signals; logging via django-fsm-log |
django-fsm-2 4.3.0: django_fsm.signals; ours: xstate-statemachine docs: Persistence, Guarantees and the integration pages |
| Visual editor | Stately editor: the chart is plain XState JSON | graph_transitions management command (Graphviz) |
django-fsm-2 4.3.0: django_fsm/management/commands/graph_transitions.py; ours: xstate-statemachine docs: Persistence, Guarantees and the integration pages |
| Typed context | [pydantic] context_model and one model per event, validated on send |
The model’s own fields are the context | django-fsm-2 4.3.0: a transition changes one field; data lives on the model; ours: xstate-statemachine docs: Persistence, Guarantees and the integration pages |
| Persistence | StatechartField on your Django model row (queryable __state / in_state()), or snapshots in SQLite, Redis, SQLAlchemy, Django ORM (DjangoStore), files |
The Django model row (native) | django-fsm-2 4.3.0: the state is a model field; ours: xstate-statemachine docs: Persistence, Guarantees and the integration pages |
| XState JSON portability | Reads XState v5 JSON (Stately export) and follows XState v5 statechart semantics; engine event names keep the v4 spelling | No: transitions are Python decorators | django-fsm-2 4.3.0: no import / export format; ours: xstate-statemachine docs: Persistence, Guarantees and the integration pages |
The same order lifecycle
A cart is checked out, then paid within 15 minutes or it expires, then shipped. It can be cancelled before payment.
Theirs: django-fsm
This is not executed here; it needs a Django project.
from django.db import models
from django_fsm import FSMField, transition
class Order(models.Model):
state = FSMField(default="cart", protected=True)
items = models.PositiveIntegerField(default=0)
def has_items(self):
return self.items > 0
@transition(field=state, source="cart", target="awaiting_payment",
conditions=[has_items])
def checkout(self): ...
@transition(field=state, source="awaiting_payment", target="paid")
def pay(self): ...
@transition(field=state, source="paid", target="shipped")
def ship(self): ...
@transition(field=state, source=["cart", "awaiting_payment"],
target="cancelled")
def cancel(self): ...
# The 15-minute expiry is not expressible: schedule a Celery task that
# calls a hypothetical expire() transition, and cancel it on pay().
Ours
The chart is data, so the timeout lives in it. Here it runs with stub_logic, which satisfies the hasItems guard:
from xstate_statemachine import SyncInterpreter, create_machine, stub_logic
chart = {
"id": "order",
"initial": "cart",
"states": {
"cart": {
"on": {
"CHECKOUT": {
"target": "awaitingPayment",
"guard": "hasItems",
},
"CANCEL": "cancelled",
},
},
"awaitingPayment": {
"after": {
"900000": "expired",
}, # 15 minutes
"on": {
"PAY": "paid",
"CANCEL": "cancelled",
},
},
"paid": {
"on": {
"SHIP": "shipped",
},
},
"shipped": {
"type": "final",
},
"expired": {
"type": "final",
},
"cancelled": {
"type": "final",
},
},
}
order = SyncInterpreter(create_machine(chart, logic=stub_logic(chart))).start()
order.send("CHECKOUT")
assert order.can("PAY") and not order.can("SHIP")
order.send("PAY")
order.send("SHIP")
assert order.current_state_ids == {"order.shipped"}
order.stop()
On a database row, the sqlalchemy_orders example runs this chart with optimistic locking, and the after deadline is fired by DueTimerScanner for orders nobody touches.
Migration recipe
The xsm_migrate_fsm management command (#310) does these steps mechanically and reversibly:
- Extract the chart.
python manage.py xsm_migrate_fsm shop.Order --field state --dry-runreads the@transitiondecorators and prints XState JSON on stdout (the recipe goes to stderr, so--dry-run > order.jsonor| python -m json.toolworks): each@transition(source=..., target=...)becomes anonentry of itssourcestate, keyed by an event named after the method (checkout→CHECKOUT, withmeta.methodrecording the original name);conditions=andpermission=become named guards (implement a permission withPermissionGuard);source="*"/"+"fan out over the states. Review it, then--write-chart shop/machines/order.json. Withoutmanage.py,DJANGO_SETTINGS_MODULE=mysite.settings xsm gt --from-django-fsm shop.Order -o shop/machineswrites the same chart asorder.json(no database access) and generates the logic and runner skeleton from it. - Keep the column. Add
statechart = StatechartField()beside theFSMField, pointstatechart_machineat the chart, and runmakemigrations/migrate. - Fill the snapshots.
python manage.py xsm_migrate_fsm shop.Order --field statewrites each row’s snapshot from its current state value withfrom_state_ids()(nothing runs: no entry actions, no services). It is batched (--batch 1000), resumable (only rows with an empty snapshot are touched), idempotent (a second run changes nothing) and safe with the site up (a row a live writer moved meanwhile is left for the next run). A value the chart does not know (a renamed legacy state, a typo, an empty string) is skipped and reported per value with its row count, never a traceback halfway; fold renames in with--map open=new(repeatable) and run again. A--mapto a state the chart lacks is refused before any row is touched, and a--mapwhose old value no row holds is flagged as a likely typo. - Dual-read for one release. Mix
FSMDualWriteMixininto the model. It is two-way: everysend()also writes the old column, so code that still readsorder.statekeeps working, and old code that still calls an FSM@transitionmethod and thensave()re-adopts the snapshot at the column’s new value, so the nextsend()starts from the right state. Then drop theFSMField.
Costs and limits of the window. FSMDualWriteMixin adds one SELECT to every save() of the model (and a fenced UPDATE + deadline rows when the FSM column moved) – an N-row loop pays N extra queries; drop the mixin once the window closes. The data migration walks primary keys forward in one pass, so a row inserted behind the cursor during the run is picked up by the next run: run the command until it reports migrated 0 row(s).
What the extracted chart records. An FSM value that is not a valid XState key ("in-progress", "état", an FSMIntegerField’s 3) gets a safe key (in_progress, état, s_3; _2, _3 on a clash) and the original value in that state’s meta.fsm_value – the data migration and the dual write map back through it exactly, so regenerate charts written before 0.11.0 with --write-chart. RETURN_VALUE / GET_STATE targets become one guarded transition per allowed state (guard <method>Returns with params.value; a dynamic target that lists no states is refused). on_error targets are states (meta.on_error), custom={...} lands in meta.custom, and guard names are stable across runs (a lambda is named after its method, e.g. goPermission; same-named conditions from different modules get a module prefix). A row adopted into a state with an after timer gets a scanner deadline due one delay after the migration (the legacy entry time is unknown). A context() hook that raises for a row leaves that row empty and names its pk; the rest of the batch and the run continue.
Code in the transition method body becomes an action, or an invoked service if it can fail; post_transition receivers keep working through our own post_transition signal (use on_commit=True for external side effects).
When to choose django-fsm instead
- Your states are a flat list with a handful of transitions, and you want the smallest possible dependency.
- Your team thinks in model methods, not in a chart, and nobody will open the machine in a visual editor.
- You never need timers, nested or parallel states, or to share the machine with a frontend.
When to choose xstate-statemachine
- The workflow has nested or parallel stages (review ∥ payment, retries inside a stage).
- Timeouts are part of the business rule and must fire after a restart.
- The same chart must run in a React/TypeScript frontend and be edited in Stately.
- You also run the workflow outside Django: in FastAPI, Flask, a worker or a CLI.
See SQLAlchemy and Persistence for how a statechart lives on a database row.
New to the library? Start with the integrations journey: pick your path, then a fifteen-minute tutorial.