statem
A minimal async state machine engine for Python, built on Pydantic. The state graph is a validated dict; guards and actions are plain Python (sync or async) functions, registered by name.
Full documentation: https://rahuldas-dev.github.io/statem/
Install · Quickstart · Hooks · Visualizing · Why it's built this way · Development · License
Install
pip install statem
The importable package is statem:
from statem import StateMachine, Signal
Quickstart
import asyncio
from statem import Signal, StateMachine
config = {
"idle": {"on": {"START": {"target": "running", "guard": "can_start"}}},
"running": {"on": {"STOP": "idle"}},
}
def can_start(ctx, signal) -> bool:
return True
async def main() -> None:
machine = StateMachine.from_dict(config, guard_dict={"can_start": can_start})
state = await machine.run(state_name="idle", events=Signal(event="START"), session={})
print(state) # "running"
asyncio.run(main())
See examples/bread.py for a fuller example (guards,
actions, error_state, and an always-transition), or
examples/bank.py for a richer one that exercises
every hook in one conversation, including error_state recovering from a real exception. The
guide covers the full config shape.
Hooks
Four lifecycle hooks can carry guards and/or actions on any state:
| Hook | Fires when | Carries |
|---|---|---|
on |
An external Signal (event) matches this state's on map (or its "*" wildcard) |
Candidates tried in order: guard (optional, must return bool) gates the candidate; its actions run if it fires |
always |
Right after any state entry, including the initial one — no external signal needed | Same shape as on, minus the event name; used to auto-advance once a condition becomes true |
entry |
A state is entered (after the firing transition's own actions) |
Action names only |
exit |
A state is left, before entering the next one | Action names only |
A fifth field, error_state, isn't a hook itself but a per-state escape hatch: if an
on-transition's action raises, the engine catches it and — if error_state is set — transitions
there instead of propagating.
Visualizing
to_mermaid(machine, initial=None) renders machine.config as a Mermaid stateDiagram-v2
string — no extra dependency, just text. GitHub, MkDocs, VS Code, and Jupyter all render it
natively:
from statem import to_mermaid
print(to_mermaid(machine, initial="idle"))
stateDiagram-v2
[*] --> idle
idle --> running: START [can_start]
running --> idle: STOP
See the guide for guard
chains, always, and error_state rendering.
Why it's built this way
- Data and behavior stay apart.
configis a plain, JSON/YAML-friendly dict — author it by hand, generate it, or load it from a file/database/LLM. Guards and actions are ordinary functions registered by name, independent of the graph. - Fails fast. Bad transition targets and unregistered guard/action names raise at construction time, not three hops into a live run.
- Sync or async, your choice. The engine detects and awaits either correctly — no need to
wrap trivial sync logic in
async def. sessionis yours. Any shape you want (dict, dataclass, ORM row) — the engine never inspects it, just threads it through to your guards/actions viactx.session.- No subclassing.
StateMachine.from_dict(...)+await machine.run(...). No framework lifecycle to inherit into. - Every run is traceable. Each guard/action fired is recorded in order; a
run_id(yours, or auto-generated) ties every log line from one run together.
Development
uv sync
uv run python -m unittest discover -s tests
uv run coverage run -m unittest discover -s tests && uv run coverage report --fail-under=100
uv run ruff check tests statem
License
MIT — see LICENSE.
Metadata
Release files for statem 0.2.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 | |
|---|---|---|---|
| statem-0.2.0.tar.gz | 78.1 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| statem-0.2.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 92.4 kB
Release files / statem-0.2.0.tar.gz
| Download URL | statem-0.2.0.tar.gz |
|---|---|
| Size | 78.1 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
229e744b33e95a8368d0cd79dbe85d90ca885da53c9c3078d185b074b3d3a7eb
|
|
BLAKE2b-256 checksum How to use checksums |
095f5326400ef3ccf9da63fe1065303304feae8709d1f1283e8d8461b6266f6f
|
| 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 1, 2026.
Transparency logRelease files / statem-0.2.0-py3-none-any.whl
| Download URL | statem-0.2.0-py3-none-any.whl |
|---|---|
| Size | 14.3 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
a660707f72b10350a56e4c824c06b7e8fb906850806a189170cdcce54d792c05
|
|
BLAKE2b-256 checksum How to use checksums |
471e5b9ded7d99ad0d5c0cc4dd5368300ad08ce99565efa73c4a7ef2e76819f9
|
| 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 1, 2026.
Transparency log