A finite state machine for Go.
go get github.com/floatdrop/fsmpackage main
import (
"context"
"errors"
"fmt"
"time"
"github.com/floatdrop/fsm"
)
// States are any comparable type. int32 here so the state can sit in a
// protobuf enum field and survive a snapshot.
type recState int32
const (
recActive recState = iota
recStopped
recFinished
recUploaded
)
func (s recState) String() string {
return [...]string{"active", "stopped", "finished", "uploaded"}[s]
}
// The state lives in your struct. The machine never holds a copy.
type recording struct {
state recState
stoppedAt time.Time
pendingUploads int
}
// Events are prefixed ev so they never read as states: "stop" the event beside
// "stopped" the state is one tense apart. The payload is the aggregate itself,
// so guards and actions see exactly what the caller has.
var (
evRecStop = fsm.Define[*recording]("stop")
evRecFinish = fsm.Define[*recording]("finish")
evRecUpload = fsm.Signal("uploaded")
)
// A guard rejects with a sentinel, so a caller can tell "not yet, retry later"
// from "not allowed at all" without parsing strings.
var errUploadPending = errors.New("still uploading")
var recordingFSM = fsm.MustNew("recording",
fsm.Initial(recActive),
fsm.From(recActive).On(evRecStop).To(recStopped).
Action(func(_ context.Context, r *recording) error {
r.stoppedAt = time.Now()
return nil
}),
fsm.From(recStopped).On(evRecFinish).To(recFinished).
Guard("all chunks and tracks uploaded", func(_ context.Context, r *recording) error {
if r.pendingUploads != 0 {
return fmt.Errorf("%w: %d left", errUploadPending, r.pendingUploads)
}
return nil
}),
fsm.From(recFinished).On(evRecUpload).To(recUploaded),
)
func main() {
ctx := context.Background()
r := &recording{pendingUploads: 2}
// Fire moves the state in place and reports the transition it made.
tr, _ := recordingFSM.Fire(ctx, &r.state, evRecStop, r)
fmt.Println(tr.From, "->", tr.To, "on", tr.Event())
// The guard refuses; the state does not move.
_, err := recordingFSM.Fire(ctx, &r.state, evRecFinish, r)
fmt.Println(err)
fmt.Println("retryable:", errors.Is(err, errUploadPending), "| state:", r.state)
// An event this state does not accept is an error, never a panic.
_, err = recordingFSM.Send(ctx, &r.state, evRecUpload)
fmt.Println(err)
r.pendingUploads = 0
_, _ = recordingFSM.Fire(ctx, &r.state, evRecFinish, r)
_, _ = recordingFSM.Send(ctx, &r.state, evRecUpload)
fmt.Println("final:", r.state)
}active -> stopped on stop
fsm recording: transition stopped --finish--> finished rejected by guard "all chunks and tracks uploaded": still uploading: 2 left
retryable: true | state: stopped
fsm recording: no transition from stopped on uploaded
final: uploaded
Every section below refers back to this machine.
New returns a machine or every mistake it found. MustNew panics instead — right for a package-level variable, so a bad definition fails at startup rather than at fire time.
Each of these returns a Rule[S], the single option type. Rules are plain values, reusable across machines.
| Rule | Declares |
|---|---|
Initial(a) |
where a fresh instance starts |
From(a).On(ev).To(b) |
one transition |
FromEach(a, b).On(ev).To(c) |
the same transition from several sources |
FromGroup(g).On(ev).To(c) |
a transition inherited by every member of a group |
From(a).On(ev).Stay() |
an internal transition: the event is handled in a, which it does not leave |
OnEnter(s, h) / OnExit(s, h) |
hooks around the assignment |
OnEnterVia(s, ev, h) / OnExitVia(s, ev, h) |
hooks that see the payload of one event |
OnEnterWith(s, h) / OnExitWith(s, h) |
hooks that see the payload of every event entering or leaving s |
OnTransition(h) |
a hook on every transition but an internal one |
OnEnterGroup(g, h) / OnExitGroup(g, h) |
hooks on the transitions that cross g's boundary; …With variants see the payload |
NewGroup(name, a, b) |
a group, usable as a rule on its own |
Rules(...) |
several rules as one value |
Guard and Action are methods on the transition, not extra arguments to To:
fsm.From(recStopped).On(evRecFinish).To(recFinished).
Guard("all chunks and tracks uploaded", uploadsSettled).
Action(sealManifest)Guards run in registration order and the first rejection wins. The description is the static condition — it names the guard in the error and labels the edge in DOT and Mermaid; the returned error is why it failed this time, and GuardError unwraps to it, so errors.Is(err, errUploadPending) works.
An action runs after every guard passed and before the state moves; if it fails, nothing is assigned and no hook runs.
Both receive the payload at its declared type.
FromEach declares one transition from each of several states:
fsm.FromEach(pcpConnected, pcpReconnecting).On(evPcpKick).To(pcpDeleted)One edge per source, with nothing relating them. Use a group when the sources are related, or when a state added later should inherit the rule.
Initial records where a fresh instance starts — the machine still holds no state. It buys two build-time checks: no unreachable state, and no start with no way out.
fsm.MustNew("recording",
fsm.Initial(recActive),
fsm.From(recActive).On(evRecStop).To(recStopped),
fsm.From(recFinished).On(evRecUpload).To(recUploaded), // nothing reaches finished
)
// panic: fsm recording: states [finished uploaded] cannot be reached from initial state activeIt is also what DOT and Mermaid draw the start marker from.
tr, err := recordingFSM.Fire(ctx, &r.state, evRecStop, r) // with a payload
tr, err := recordingFSM.Send(ctx, &r.state, evRecUpload) // for events declared with SignalFire returns the Transition it made: tr.From, tr.To, and tr.Event() for display. Names are not unique, so branch with tr.Is(evRecStop), never on the string.
A caller that fires whenever an event might apply — a controller reconciling on every turn — wants a refusal to be no news:
tr, fired, err := m.TryFire(ctx, &st, evReconcile, x) // fired is false, err nil, when nothing appliesTryFire (and TrySend) report a state that does not take the event, or a guard that rejects it, as false with a nil error, and allocate nothing doing so. A failed action, or a state written by a callback, is still an error.
To ask without firing:
err := m.Check(ctx, from, ev, payload) // nil, or the same error Fire would return
ok := m.Can(ctx, from, ev, payload) // Check with the reason discarded
to, ok := m.To(from, ev) // the target, ignoring guardsCheck evaluates guards but runs no action and no hook.
A machine is immutable once New returns, so one value serves every goroutine that owns a state, with no lock. Hooks are shared, so whatever they touch is yours to synchronize.
Every fire-time failure is a typed error carrying the edge. Match with errors.AsType:
| Error | Means | State |
|---|---|---|
*NoTransitionError[S] |
this state does not accept this event | untouched |
*GuardError[S] |
a guard rejected; unwraps to the guard's error | untouched |
*ActionError[S] |
the action failed; unwraps to the action's error | untouched |
*StateChangedError[S] |
a guard or action wrote the state through an aliased payload | whatever the callback left |
A nil state pointer or the zero Event is a plain error — a caller bug, not a machine event.
StateChangedError catches the aliasing trap: when the payload is the struct holding the state, a guard or action that writes r.state itself would leave you running from a state the machine never assigned. It is reported ahead of any error the callback also returned, which is kept in Err but deliberately not unwrapped — a guard's sentinel means "state untouched, retry", and matching it here would re-fire from the wrong source. Read it with errors.AsType.
Hooks run bookkeeping around the assignment. They return nothing: everything that can fail does so before the state moves, so a hook never runs for a transition that did not happen.
fsm.OnEnter(recStopped, func(_ context.Context, tr fsm.Transition[recState]) { ... })
fsm.OnExit(recActive, func(_ context.Context, tr fsm.Transition[recState]) { ... })
fsm.OnTransition(func(_ context.Context, tr fsm.Transition[recState]) {
log.Info("recording", "from", tr.From, "to", tr.To, "on", tr.Event())
})The order inside Fire is fixed:
lookup → guards → action → the check that neither wrote the state →
OnExit(from)→ assignment →OnTransition→OnEnter(to)
OnTransition precedes the entry hooks so an entry hook that fires again is logged after its cause; the price is that a transition hook must observe and not fire. A self-transition From(a).On(ev).To(a) is UML's external kind — it runs exit then entry.
An event a state handles without leaving it — a heartbeat that refreshes a deadline, a vote that is counted — is declared with Stay, UML's internal transition:
fsm.From(follower).On(evHeartbeat).Stay().Action(recordLeader)Guards and actions run as on any transition, and Fire returns a transition from the state to itself; the state is not left, so no hook runs, OnTransition included. Keeping such events in the table is what lets it say what each state accepts: a NoTransitionError still means the state refuses the event, Check and Can answer for it, and DOT draws it as a dashed loop, Mermaid as a line inside the state. It is not a way out, so a state with only internal transitions is still in Terminals().
A plain Hook sees the transition but not the payload, because a state can be entered by events carrying different types. OnEnterVia and OnExitVia name the event, which fixes the type:
fsm.OnExitVia(recActive, evRecStop, func(_ context.Context, tr fsm.Transition[recState], r *recording) {
log.Info("stopping", "pending", r.pendingUploads)
})OnEnterWith and OnExitWith do the same for every event entering or leaving the state, which is what a hook that must not miss a way in needs — a counter labelled by the payload, typically:
fsm.OnEnterWith(recStopped, func(_ context.Context, tr fsm.Transition[recState], r *recording) {
log.Info("stopped", "on", tr.Event(), "pending", r.pendingUploads)
})They read the finished transition table, group-inherited edges included, so an event added later is covered without another declaration. That requires every such event to carry the hook's payload type; New reports one that does not, rather than let the hook silently miss it.
New rejects a typed hook that no transition could trigger, so a terminal state takes OnEnterWith without OnExitWith. Plain hooks run first, then Via hooks, then With hooks.
A machine whose hooks all need the aggregate — a controller, an elector — tends to give every event one payload type: a small struct holding the aggregate and whatever the event adds. That is what lets OnEnterWith serve every state, at the price of per-event typing; name events with distinct payloads where that typing matters more.
An entry hook may fire the machine again, on the state it just entered: a start that fails can move straight back out. A package-level machine whose hook names it would be a variable initialized with itself, which Go rejects as an initialization cycle. Reach the machine through the payload instead — the aggregate holds the machine it runs on — and the variable stays a plain declaration:
type job struct {
phase phase
machine *fsm.Machine[phase] // the machine it runs on, for hooks that fire it
}
var lifecycle = fsm.MustNew("job",
fsm.From(idle).On(evStart).To(starting),
fsm.From(starting).On(evStartFailed).To(idle),
fsm.OnEnterVia(starting, evStart, func(ctx context.Context, _ fsm.Transition[phase], j *job) {
if err := j.launch(); err != nil {
_, _ = j.machine.Fire(ctx, &j.phase, evStartFailed, j) // through the job, not by name
}
}),
)Building the machine in init also compiles, but only hides the cycle from the compiler.
NewGroup names a set of states. A transition declared on the group applies to every member:
type pcpState int32
const (
pcpConnected pcpState = iota
pcpReconnecting
pcpDeleted
pcpAbandoned
)
func (s pcpState) String() string {
return [...]string{"connected", "reconnecting", "deleted", "abandoned"}[s]
}
// A disconnect carries why it happened, so the reason cannot be lost between
// the caller and the guard that reads it.
type disconnect struct {
intentional bool
reason string
}
var (
evPcpDrop = fsm.Define[disconnect]("disconnect")
evPcpReconnect = fsm.Signal("reconnect")
evPcpKick = fsm.Define[disconnect]("kick")
)
// Connected and reconnecting are both live, and both are kicked the same way.
var pcpLive = fsm.NewGroup("live", pcpConnected, pcpReconnecting)
var participantFSM = fsm.MustNew("participant",
fsm.Initial(pcpConnected),
fsm.From(pcpConnected).On(evPcpDrop).To(pcpReconnecting).
Guard("disconnect was not intentional", func(_ context.Context, d disconnect) error {
if d.intentional {
return fmt.Errorf("left on purpose: %s", d.reason)
}
return nil
}),
fsm.From(pcpReconnecting).On(evPcpReconnect).To(pcpConnected),
// One rule for every live state, including any added later.
fsm.FromGroup(pcpLive).On(evPcpKick).To(pcpDeleted),
)A member that declares the event itself overrides the group rule; the others keep the inherited one:
fsm.From(pcpReconnecting).On(evPcpKick).To(pcpAbandoned) // overrides the groupThat override is what FromEach cannot do, and the reason to reach for a group.
A group is not a state: it never appears in States(), a *S never holds one, and it expands into ordinary rows before New returns, so Fire pays nothing for it. Each row records where it came from:
for _, e := range participantFSM.Edges() {
if e.Group != "" {
fmt.Printf("%v --%s--> %v inherited from %q\n", e.From, e.Event(), e.To, e.Group)
}
}
// connected --kick--> deleted inherited from "live"
// reconnecting --kick--> deleted inherited from "live"New rejects an unknown member, a repeated one, a name reused for different members, two groups claiming one event for the same state, and a group transition every member overrides.
A group has entry and exit hooks, which run as a superstate's do: only on a transition that crosses its boundary.
fsm.OnEnterGroup(pcpLive, func(_ context.Context, tr fsm.Transition[pcpState]) { live.Inc() })
fsm.OnExitGroup(pcpLive, func(_ context.Context, tr fsm.Transition[pcpState]) { live.Dec() })A move between members — connected to reconnecting and back — runs neither, so the count never dips. OnEnterGroupWith and OnExitGroupWith see the payload, which every event crossing the boundary must then carry. A transition leaving several groups leaves the smaller one first, and one entering several enters the larger one first, as a substate is left before its superstate and entered after it. The exit hooks of a group run after the state's own, and its entry hooks before the state's own.
Groups cover most of what substates are for, but not all: no nesting as states, no initial member. Why, in docs/DESIGN.md.
States, Events, Edges, Groups, Terminals, Unreachable and Initial report the machine's shape in declaration order, so the output is stable and worth asserting on:
func TestRecordingShape(t *testing.T) {
// Only uploaded is a dead end.
if got := recordingFSM.Terminals(); len(got) != 1 || got[0] != recUploaded {
t.Errorf("terminals %v, want [uploaded]", got)
}
// Every state is reachable from active.
if got := recordingFSM.Unreachable(recActive); len(got) != 0 {
t.Errorf("unreachable states: %v", got)
}
}An unintended terminal is somewhere a value gets stuck; an unreachable state usually means a missing transition. Declaring Initial turns the second check into a build error.
DOT and Mermaid draw the machine.
Edges returns the table, guard descriptions included:
for _, e := range recordingFSM.Edges() {
fmt.Printf("%v --%s--> %v %s\n", e.From, e.Event(), e.To, e.Guard)
}
// active --stop--> stopped
// stopped --finish--> finished all chunks and tracks uploaded
// finished --uploaded--> uploadedEdge.Event() and Transition.Event() are names, for display; identify a trigger with e.Is(evRecStop).
DOT renders a Graphviz digraph: a dot points at the initial state, terminals are double circles, guards appear in edge labels, and a group becomes a cluster with inherited transitions drawn once from the boundary.
go run ./yourcmd | dot -Tsvg -o machine.svgdigraph "recording" {
rankdir=LR;
"__start" [shape=point];
"active" [shape=box];
"stopped" [shape=box];
"finished" [shape=box];
"uploaded" [shape=doublecircle];
"__start" -> "active";
"active" -> "stopped" [label="stop"];
"stopped" -> "finished" [label="finish\n[all chunks and tracks uploaded]"];
"finished" -> "uploaded" [label="uploaded"];
}A group inside another is drawn inside its cluster. A group that partly overlaps an earlier one cannot be drawn as a cluster without boxing the wrong states, so it gets none, and a comment in the output says so. A boundary arrow is drawn only when it would be accurate — per-member arrows are used when a member overrides the event, when the group has no cluster, or when the target is itself a member. Output follows declaration order, so it can be committed next to the code and diffed.
Mermaid renders the same diagram as a Mermaid state diagram, for a mermaid block in Markdown, which GitHub draws in place. Groups are composite states, nested, left out and drawn from as DOT draws its clusters; an internal transition is a line inside its state, below the name; [*] points at the initial state, and a terminal points at [*]. This is participantFSM from Groups:
---
title: "participant"
---
stateDiagram-v2
direction LR
state "live" as g0 {
s0
s1
}
s0 : connected
s1 : reconnecting
s2 : deleted
[*] --> s0
s0 --> s1 : disconnect<br>[disconnect was not intentional]
s1 --> s0 : reconnect
g0 --> s2 : kick
s2 --> [*]
A block in a README goes stale as the machine changes; a test that compares it with Mermaid() keeps it honest. States and groups are numbered by declaration order (s0, g0) and labelled with their names, so any name is safe: Mermaid's syntax characters are written as entity codes.
Guards and actions are combined at construction, where the payload type is still known, and stored as a concrete func(context.Context, A) error. Fire asserts back to that type and passes the payload directly, so nothing is boxed. Hooks are worked out at construction too: every row gets a plan, the hooks it runs before the assignment and after it, so a machine with hooks does one lookup per fire, and one without skips even that on a flag.
goos: darwin, goarch: arm64, cpu: Apple M3 Max
BenchmarkFire-14 33.00 ns/op 0 B/op 0 allocs/op
BenchmarkFireWithHooks-14 43.69 ns/op 0 B/op 0 allocs/op
One iteration is two fires, one with an action, so a single Fire is about 16 ns. BenchmarkFireWithHooks is the same round trip with an entry hook, an exit hook and both payload hooks.
The same a → b → a cycle, built once and fired in a loop; one iteration is two transitions. Source in benchmarks/, a separate module so the root stays dependency-free.
| plain | guard + action | payload | |
|---|---|---|---|
floatdrop/fsm |
26.1 ns, 0 allocs | 49.6 ns, 0 allocs | 35.6 ns, 0 allocs |
qmuntal/stateless v1.8.0 |
188 ns, 0 allocs | 269 ns, 6 allocs | 267 ns, 6 allocs |
looplab/fsm v1.0.4 |
405 ns, 10 allocs | 562 ns, 10 allocs | 545 ns, 12 allocs |
These are different designs, not a ranking:
- They hold the state. A
looplab.FSMorstateless.StateMachineowns a current state, so you need one per entity and the fire path takes a lock. Afsm.Machineis configuration shared by every entity — most of the gap, and also why it has noState(). - They are
any-typed. States, triggers and payloads areany, which is where the allocations come from; it also makes a misspelled trigger a runtime error rather than a compile error. - They do more.
statelesshas hierarchical substates, dynamic destinations and a trigger queue;looplabhas async transitions and metadata. If you need those, this is the price of a feature, not overhead.
Re-measure with -count=6 before quoting a number; single runs vary ~10%.
The reasoning behind the API — state ownership, typed payloads, Rule as the single option type, guard semantics, why groups are a build-time expansion, and the known limitations — is in docs/DESIGN.md.
MIT, see LICENSE.
