Pydantic integration
Typed, validated context and events for statecharts with Pydantic v2 — plus static config validation and JSON Schema for your frontend.
Modern Python expects typed, validated data at every boundary. python-statemachine and transitions are stringly typed; XState v5’s setup() gives TypeScript users typed context and events. The Python equivalent is Pydantic v2 — and it is the validation layer the FastAPI, DRF and agents integrations reuse. Silent acceptance is a bug: a typo’d context key or a malformed payload should fail at the boundary, not deep inside an action. This extra makes that the default, through seams core already has — core never imports pydantic.
Install
pip install "xstate-statemachine[pydantic]"
Requires pydantic>=2.5. Tested versions are in the compatibility table.
Runnable example. examples/integrations/fastapi_orders uses this extra: its models.py imports EventModel and events_union from xstate_statemachine.contrib.pydantic, and its app.py imports context_model. The agents_support_bot example does not import the extra itself; its lookup_order tool calls the fastapi_orders app in-process, so it exercises these validated events through that app’s HTTP surface.
Quick start
from decimal import Decimal
from typing import Literal
from pydantic import BaseModel
from xstate_statemachine import MachineLogic, SyncInterpreter, create_machine
from xstate_statemachine.contrib.pydantic import (
ContextValidationError,
EventModel,
TypedContextPlugin,
context_model,
context_of,
events_union,
typed_context,
)
class OrderContext(BaseModel):
total: Decimal = Decimal(0)
currency: Literal["USD", "EUR"] = "USD"
class Pay(EventModel): # an instance IS an event
type: Literal["PAY"] = "PAY"
amount: Decimal
cfg = {
"id": "order",
"initial": "open",
"actionErrorPolicy": "rollback",
"states": {
"open": {
"on": {
"PAY": {
"actions": "add",
},
"BREAK": {
"actions": "bad",
},
},
},
},
}
logic = MachineLogic(
actions={
"add": lambda i, ctx, e, a: ctx.__setitem__(
"total", ctx["total"] + e.payload["amount"]
),
"bad": lambda i, ctx, e, a: ctx.__setitem__(
"currency", "GBP"
), # not in the Literal
}
)
machine = create_machine(
typed_context(OrderContext, cfg), # initial context validated + completed
logic=logic,
context_validator=context_model(
OrderContext
), # re-validated after every mutating action
event_schemas=events_union(Pay), # payloads validated at send()
)
order = SyncInterpreter(machine).use(TypedContextPlugin(OrderContext)).start()
order.send(Pay(amount=Decimal("9.50"))) # typed event, no dict
assert context_of(order, OrderContext) == OrderContext(total=Decimal("9.50"))
receipt = order.send("BREAK", wait=True) # the action breaks the model...
assert isinstance(
receipt.error, ContextValidationError
) # ...reported as an action error...
assert (
order.context["currency"] == "USD"
) # ...and rolled back: never an invalid context
Reference
Typed context
| Name | What it does | |
|---|---|---|
context_model(model, *, write_back=True) -> callable |
Builds the context_validator= for create_machine(). The engine calls it after any action that changed context (never when unchanged); a failure is ContextValidationError, an action error, so actionErrorPolicy: "rollback" restores the previous context. With write_back the model’s defaults and coerced values are written into the live dict (ctx["total"] is a Decimal; nested models are written as plain dicts so snapshots stay restorable). Neither the error’s str() nor its .errors() carries the offending value (input is "<redacted>"), so a bad card_token never reaches a log line. Cost: with a validator configured, every action deep-copies context for the dirty check and a mutating action re-validates the whole model — about 25 ms per action for a 100 000-element list; keep large blobs out of a validated context. |
|
typed_context(model, config) -> config |
Validates and completes the chart’s "context" before create_machine(), so the initial context satisfies the model. Does not mutate config. |
|
TypedContextPlugin(model) |
Re-validates (and re-coerces) the context on on_interpreter_start — fresh or restored. A snapshot stores Decimal("9.5") as "9.5"; this plugin makes it a Decimal again before the first action. Attach it wherever you use context_model. |
Under persisted() / apersisted() / persisted_retry a start that this plugin fails raises the ContextValidationError before the block runs and nothing is saved (the stored record is untouched). |
context_of(interpreter, model) -> model |
A typed view: model.model_validate(interpreter.context). Fresh each call; the dict stays the source of truth. |
|
PydanticCodec(model) |
A store codec= that serialises context through the model’s JSON mode (Decimal → exact string, datetime → ISO 8601) instead of the engine’s str() fallback. Pair with TypedContextPlugin for the way back. It sees the snapshot after the engine’s str() fallback: Decimal and aware/naive datetime round-trip, but an Enum member arrives as "Color.R" (use use_enum_values=True) and bytes as "b'...'" (store base64 str). A context the model refuses is stored unchanged with a RuntimeWarning naming the field paths. The codec does not redact — the stored blob holds secrets; redact() is for logs. Stateless; one instance may be shared across threads. |
|
ContextValidationError |
XStateMachineError with .model, .cause (the pydantic ValidationError) and .errors (its .errors() list). |
Typed events
| Name | What it does |
|---|---|
EventModel |
Base class: type: Literal["PAY"] = "PAY" + payload fields (extra="forbid"). Implements __xstate_event__, so an instance is accepted by send(), send_events(), send_threadsafe() and sendTo on both engines; type is the event type, every other field the payload (Python-mode dump — Decimal stays Decimal); also send_priority() and a from_callback send_back(model). A pending event whose payload holds a Decimal makes get_snapshot() raise SnapshotSerializationError (snapshot a settled machine, or use float/str fields) — it is never silently turned into a string. Cost: send(Model) runs about 48k events/s vs 65k/s for the dict form (one model dump + one validation per send). |
event_type_of(model) -> str \| None |
The single Literal value of a model’s type field, else its str default, else None (a multi-value Literal is None). |
events_union(*models) -> dict |
The event_schemas= mapping (type → validator). A bad payload raises the existing InvalidEventPayloadError at the send() call site with the pydantic ValidationError as .cause; an undeclared type is rejected under strict. Duplicate types are a TypeError. |
models_of(schemas) |
The models behind an events_union() mapping. |
Config validation
| Name | What it does |
|---|---|
validate_machine_json(raw, *, strict=False) -> MachineConfig |
A Pydantic model of the XState JSON subset this library implements. raw is a dict, a JSON str or UTF-8 bytes (a BOM is tolerated). Raises InvalidConfigError with JSON paths (states.paying.on.PAY[0].target: Input should be a valid string) before create_machine(): missing/empty id, an initial naming no child, wrong type / policy values, a non-integer maxIterations, a non-object context, a duplicate custom state id, a non-boolean strict / strictTargets / strictConfig (the parser reads "false" as true), malformed transitions and invokes, malformed JSON, and JSON text that repeats a key in one object. strict=True also reports every unrecognised key with its path (x- keys are always allowed), mirroring strictConfig — Stately’s $schema, types, tsTypes, schemas, delays, predictableActionArguments, preserveActionOrder are refused there too (prefix them x- or drop them). The gate stops at about 95 levels of nested states (pydantic-core’s recursion cap) and says so; create_machine parses deeper. Unknown transition targets remain the parser’s job. |
MachineConfig / StateConfig / TransitionConfig / InvokeConfig |
The models. MachineConfig.all_state_ids(), .unknown_key_paths(). A test keeps their fields in lock-step with the parser’s known-key sets and checks the whole Stately corpus. |
JSON Schema
| Name | What it does |
|---|---|
machine_json_schema(machine, *, events=None, context_model=None, title=None) -> dict |
A JSON Schema (2020-12) with properties.event (a discriminated oneOf on type), properties.context, properties.state (an enum of every state id + x-leaf-states, the leaves that can be active — history pseudo-states excluded), $defs, and x-machine-id / x-machine-version / x-machine-hash so a frontend or OpenAPI consumer can tell which chart revision produced it. events defaults to the models behind the machine’s event_schemas. Events and context are generated in one pass, so two nested models with the same class name get distinct $defs keys. Decimal fields appear as anyOf: [number, string] — send money as a string. A union of two or more events needs a Literal type on each (TypeError otherwise). |
Guarantees
What this does: the machine never holds a context that violates the model — an action that breaks it is an action error and
rollbackrestores the previous context; a payload that does not fit itsEventModelnever enters the machine; a chart that failsvalidate_machine_jsonnever reachescreate_machine.What this does not do: validate context on every read (only after a mutating action, and on start with
TypedContextPlugin); type-check guards or services; replacecreate_machine’s own tree validation (unknown targets, deadalwaysloops). WithactionErrorPolicy: "continue"(the pre-1.0 default) an invalid context IS kept and only reported — set"rollback".See the programme-wide Guarantees and Security pages (#303).
Threat model
Who can call this: whoever can
send()— the same surface as any event. Validation runs in the sender’s call, before queueing, so a malformed payload costs the sender, not the machine.What it exposes:
ContextValidationError/InvalidEventPayloadErrormessages include pydantic’s field paths and messages (not values by default — pydantic omitsinputfromstr());machine_json_schemapublishes your event and context shapes — review it before serving it publicly.You must configure:
actionErrorPolicy: "rollback"for the never-invalid guarantee;extra="forbid"on your context model if unknown keys should be refused; the storecodec=ifDecimal/datetimeprecision at rest matters.
Compatibility
| pydantic | Python | Tested in CI |
|---|---|---|
| 2.5 – 2.x | 3.9 – 3.15 | ✅ [pydantic] cell (Linux, Windows) |
Troubleshooting
| Symptom | Cause | Fix |
|---|---|---|
InvalidConfigError: the machine's initial context does not satisfy its context model at create_machine |
the chart’s static context fails the context_model (a Literal, a bound, a missing required field) |
fix the chart’s context – the message names the field path, never the value (#266 battle) |
status == "error" right after start(), no event processed, no entry action ran |
a RESTORED context (edited at rest, or written by another version) fails the model; TypedContextPlugin failed the machine |
inspect interpreter.error (a ContextValidationError); fix the stored record or the model; nothing ran on the refused context |
ContextValidationError raised by persisted() / apersisted() before the block |
the loaded record’s context fails the model | the record is not overwritten with an error-status snapshot; repair it, or load without the plugin to migrate |
KeyError from write-back on a field with exclude=True (pre-0.11) |
model_dump() omits excluded fields |
fixed: excluded fields keep the instance’s value; computed fields are never injected |
RuntimeWarning: PydanticCodec stored a context the model refuses |
the snapshot being saved fails the codec’s model | the blob is stored unchanged; the next TypedContextPlugin restore fails loudly – fix the model or the writer |
| a card token / secret visible in a log line or a receipt after a validation failure (pre-0.11) | pydantic’s error text embeds input_value= and custom-validator messages interpolate the value |
fixed: errors are rebuilt value-free (path, type, allow-listed ctx rule keys only); raise exceptions whose text does not embed the value anyway |
InvalidConfigError: ... multi-target transition(s) whose targets cannot be entered together |
a target list whose entries are not in different regions of one parallel state |
multi-target transitions are supported (XState v5) only for targets in different regions of one parallel state; split the transition otherwise |
MissingExtraError: … pip install "xstate-statemachine[pydantic]" |
extra not installed | run the command |
Restored machine’s action fails with unsupported operand type(s) for +: 'str' and 'Decimal' |
a snapshot stores Decimal as text |
attach TypedContextPlugin(Model) (and use PydanticCodec on the store) |
ContextValidationError but the context kept the bad value |
actionErrorPolicy is "continue" |
set "rollback" on the machine |
TypeError: event type 'PAY' declared by both … |
two EventModels share a type |
make discriminators unique |
validate_machine_json accepts a chart create_machine rejects |
unknown targets / dead loops need the whole tree | run both; the model is the fast, path-reporting first pass |