00 · methodology
Three measurements, each honest about what it shows — including the ones
statecraft does not win. Every figure below comes from one full run of the reproducible
scripts in benchmark/;
run them yourself with docker compose run --rm bench (smoke) or the full
command from the benchmark README.
The stacks are isolated: separate tables, separate models, identical data. Bars show iterations per second — one iteration is the whole described operation, and higher is better; bar widths are relative to each chart's leader. Numbers are a single machine's snapshot — the shape of the comparison is the point, not the absolute i/s.
01 · reading state
statecraft and aasm store current state in an indexed column — counting
paid orders is the same index-only scan for both. statesman derives state from its
transition table, so in_state(:paid) pays a most_recent join.
Honest notes. The column read is a tie by construction and measures as one:
statecraft and aasm run the same index-only scan over identical tables.
An earlier revision of this page showed a 1.46x gap here — that was a methodology bug,
not a difference: sequential measurement over freshly bulk-loaded tables charged the
first-measured stack for PostgreSQL's hint-bit writes and empty visibility map. The
corpus is now vacuumed before any measurement and an interleaved cross-check confirms
parity (~3.9 vs ~4.0 ms per query). statesman's 5.48x is architecture — the
most_recent join. On the page load the ranking splits for a model-layer
reason: instantiating 100 aasm records costs 3x plain ones — the aasm module rides
every instance.
02 · eight writers, one record
Eight threads load the same pending order and fire the same transition. Correctness, not speed: who wins, what the losers see, what lands in the database.
| gem | successes | losers see | log rows | callbacks fired | lost updates |
|---|---|---|---|---|---|
| statecraft | 1 of 8 | 7 × Statecraft::TransitionConflict — deterministic, rescuable, savepoint already rolled back | 1 | 1 | 0 |
| statesman | 1 of 8 | 7 × Statesman::TransitionConflictError — caught via the unique index on the transition INSERT | 1 | 1 | 0 |
| aasm | 8 of 8 | nothing — every thread believes it won | — | 8 | 7, state overwritten silently |
Honest notes. aasm without an explicit lock is not broken — it simply makes
no concurrency promise: guards run on a stale snapshot, callbacks fire once per
"winner", and the column ends up written eight times. Both statecraft and statesman
keep exactly one truth; the difference is the shape of the refusal — a first-class
conflict error from a CAS UPDATE versus a unique-index violation surfacing
from an INSERT.
03 · the price of one transition
One uncontended pay/reset round trip per iteration. Every statecraft
iteration writes two log rows inside savepoints with CAS updates; aasm does two plain
UPDATEs; statesman two INSERTs plus most_recent flips.
Honest notes. This is the benchmark statecraft was expected to lose: a
savepoint, a CAS UPDATE and a log INSERT against aasm's
single UPDATE. Measured on PostgreSQL, the difference lands within the
run's error margin (~12% on the mean) — commit latency dominates, and the audit log
rides along almost for free. It is still not free: if you need neither the log nor
conflict detection, aasm's write path is the cheapest by construction.
04 · when to pick what
| pick | when | you accept |
|---|---|---|
| statecraft | state lives in a column, every transition must be race-safe and leave an append-only audit row with write-once metadata | a savepoint + CAS + log INSERT on every write; PostgreSQL first |
| statesman | history is the first-class record and you prefer no state column at all | a most_recent join on every read; conflicts surface as index violations |
| aasm | you need a readable DSL and neither an audit log nor concurrency guarantees | lost updates under concurrent writers; the cheapest single-writer path there is |
All three are fine gems. This page exists so the trade-off is measured,
not vibes-based — rerun it on your hardware before deciding:
docker compose run --rm bench.