State machines in Rails
Every application grows a column called status or state, and the moment it has more than two
values somebody searches for a rails state machine and comes back with a list of gems. The question
gets posed as a gem question, and it is not one. The real question is which of four guarantees you
need: a vocabulary of legal values, a vocabulary of legal moves, a hook that fires on a move, and a
record of the moves that happened. Rails gives you the first for free, the second costs about twenty
lines, and only the fourth is genuinely awkward to build by hand.
Everything below was run against activerecord 8.1.3.1 and PostgreSQL 17.7, with aasm 6.0.0, state_machines 0.202.0 with state_machines-activerecord 0.200.0, and statesman 13.3.0. The outputs are copied out of the scratch scripts, not remembered.
A transition table and two methods
Start from the column. A four-state support ticket, integer-backed, the way Rails enum in practice argues for:
enum :status, { open: 0, triaged: 1, resolved: 2, closed: 3 }, default: :open
That declaration gives you open?, triaged!, Ticket.closed and Ticket.not_open, and it gives
you nothing at all about order. A brand new ticket answers this:
new ticket status: "open"
open? true
after t.closed! -> "closed"
One call took an untriaged ticket straight to closed, because as far as Active Record is concerned
status is a column with four allowed values and no opinion about which follows which.
The missing piece is one frozen hash and two methods:
TRANSITIONS = {
"open" => %w[triaged],
"triaged" => %w[resolved],
"resolved" => %w[closed triaged],
"closed" => []
}.freeze
def may_move_to?(to) = TRANSITIONS.fetch(status).include?(to.to_s)
def next_statuses = TRANSITIONS.fetch(status)
fetch rather than [] on purpose: a status with no entry in the table is a KeyError at the
first call rather than an empty array that silently forbids everything. Asked the same question, the
same brand new ticket now says something useful:
next_statuses from open: ["triaged"]
One assertion keeps the hash and the enum in step, and it is the whole test:
expect(Ticket.statuses.keys - Ticket::TRANSITIONS.keys).to be_empty
At this point you have a state machine without a gem that answers "what can this ticket do next", which is what a view needs to render buttons, and that is a real fraction of the value.
The bang methods that walk around the table
A move_to! method that checks the table before saving looks like the answer, and it is not, because
t.closed! is still sitting there and still ignores it. Every enum value generates a bang, every
bang is an update!, and no amount of discipline stops the one call site that reaches for the
obvious method name.
Move the check into a validation and the shortcut closes:
validate :status_change_is_allowed, on: :update
def status_change_is_allowed
return unless status_changed?
return if TRANSITIONS.fetch(status_was, []).include?(status)
errors.add(:status, "cannot move from #{status_was} to #{status}")
end
Now the bang method routes through validation like everything else:
t.closed! -> ActiveRecord::RecordInvalid: Validation failed: Status cannot move from open to closed
row is still open
walked the path -> closed
reopening a closed ticket -> ActiveRecord::RecordInvalid: Validation failed: Status cannot move from closed to triaged
And the non-raising form comes free, which is what a controller actually wants:
t.valid? = false, errors = ["Status cannot move from closed to triaged"]
Two holes remain, and they remain in every design in this post:
update_column -> triaged
update_all -> closed
update_column skips validations by definition and update_all never instantiates a record. Neither
is a flaw in the approach, they are the documented behaviour of two methods whose whole purpose is to
skip the model, and the same two methods break counter_cache for
the same reason. The only layer that can stop them is the database,
which is the trigger further down.
What the gems put on top
Twenty lines of hash buys the transition table. What the gems sell is the other three things: a vocabulary of events, callbacks anchored to a transition rather than to a save, and introspection.
AASM names the moves:
aasm column: :state do
state :open, initial: true
state :triaged, :resolved, :closed
event :triage do
transitions from: :open, to: :triaged
end
event :close do
transitions from: [:resolved, :triaged], to: :closed
end
end
The event is the part the hash does not express. triage is a thing a person does; status =
"triaged" is a thing a column becomes. Once the move has a name, the guard, the callback and the
audit entry all have something to hang off. And the object can answer questions about itself:
state: open open? true
permitted events: [:triage]
may_triage? true may_close? false
after triage!: triaged
AASM::InvalidTransition: Event 'triage' cannot transition from 'triaged'.
state_machines goes further on introspection, and state_paths is the one method here I cannot
cheaply reproduce by hand:
state_events: [:triage]
state_transitions: ["open->triaged via triage"]
paths from open: ["triage,resolve,close", "triage,close"]
Two complete routes from open to closed, computed by walking the graph. That is the method you call
in a spec to assert that no state is unreachable and that no state is a dead end you did not intend,
and writing it against a TRANSITIONS hash is a breadth-first search you now have to maintain.
The two gems also disagree about failure, which matters when you pick one. state_machines returns false and adds a model error, so a form renders:
close (non-bang) returns false, state still open
errors after failed close: ["State cannot transition via \"close\""]
close! -> StateMachines::InvalidTransition: Cannot transition state via :close from :open (Reason(s): State cannot transition via "close")
AASM's non-bang form does something else entirely, covered two sections down.
The AASM callback that fires after the write
Most events want a side effect. Stamp closed_at, send the email, decrement the queue. AASM's README
prints the callback order at line 270, and one line in the middle of it decides where your side effect
belongs:
transition after
new_state before_enter
new_state enter
...update state...
event after
transition after runs before the write. event after runs after it. Same word, two positions, and
the difference is a column that stays NULL. Both forms, on the same table, one ticket each:
event :close_late do
after { self.closed_at = Time.current } # event level
transitions from: :open, to: :closed
end
event :close_early do
transitions from: :open, to: :closed,
after: -> { self.closed_at = Time.current } # transition level
end
event-level after -> closed_at in db: nil
transition-level after-> closed_at in db: 2026-09-24 14:24:37.167352 UTC
Nothing raised. close_late! returned true, the state column says closed, and closed_at is
NULL forever. The assignment happened, on an object that had already been saved, and then went out of
scope.
This is the strongest argument I know for the gems over the hash, and it cuts both ways. The hash
version has one place a side effect can go, which is after update! returns, and it is obviously
after. AASM has nineteen named callback positions and two of them are called after. Read the
README's ordering block before you write your first one, and put anything that assigns an attribute
on the transition, not on the event.
AASM 6.0 made a rejected save loud
AASM 6.0.0 shipped on 5 July 2026 and its changelog has five entries. The first one changes behaviour
in every existing application: "Let whiny_persistence: true be the default now". That is
lib/aasm/base.rb:27, and the consequence is at
lib/aasm/persistence/active_record_persistence.rb:73:
def aasm_raise_invalid_record
raise ActiveRecord::RecordInvalid.new(self)
end
Before 6.0.0 an event whose save failed validation returned false and left you to notice. Now:
ActiveRecord::RecordInvalid: Validation failed: Closed at can't be blank
in-memory state after the rollback: open
row in the database: open
The rollback is the good part. The object is back in open, not sitting in closed over a row that
says open, which is the state mismatch that used to make these bugs so hard to read. Upgrading to
aasm 6 means every close! call site that used to fall through on a validation failure now raises,
and that is worth a grep before the deploy rather than after.
One more asymmetry in the same area, and it is the opposite of the Rails convention everyone has in
their fingers. lib/aasm/base.rb:127 defines the bang with {:persist => true} and line 132 defines
the plain form with {:persist => false}:
non-bang close returns: true (no save attempted)
ticket.close transitions the in-memory object and writes nothing. In Active Record the bang means
"raise instead of returning false"; in the aasm gem it means "touch the database". Both are
defensible and they are not the same convention, so the plain form is worth avoiding entirely unless
you meant it.
One column, an enum and a machine
state_machines-activerecord 0.102.0 added Rails enum integration, and 0.200.0 still carries it. The
README promises that the gem "automatically detects the conflict and provides seamless integration"
when an enum and a state_machine share an attribute. Running it on the four-state ticket, the
detection part is true:
enum_integrated? true
enum_mapping {"open" => 0, "triaged" => 1, "resolved" => 2, "closed" => 3}
t.open? (enum) = true t.status_open? (machine) = true
after triage!: "triaged" (column holds 1)
The seamless part is where I would stop. Class definition printed four of these, one per state:
Instance method "open?" is already defined in ... at active_record/enum.rb:307,
use generic helper instead or set StateMachines::Machine.ignore_method_conflicts = true.
Defining :status state machine on Ticket.
The prefixed bangs it generates are not methods, they are tombstones:
t.status_resolved! -> RuntimeError: status_resolved! is a conflict-resolution placeholder.
Use the original enum method 'resolved!' or state machine events instead.
And the enum's own bang is preserved, which means the thing the state machine exists to prevent is still one method call away:
enum bang t.closed! -> closed (machine had close from triaged, but this skipped it)
Worse, writing the declaration the way the README shows it, with no initial: on the state machine,
breaks inserts outright. The machine overrides the enum's default with nothing and the first
create! dies:
PG::NotNullViolation: ERROR: null value in column "status" of relation "tickets" violates not-null constraint
A second warning in the same boot says so out loud: "Both Ticket and its :status machine have defined
a different default for status. Use only one or the other for defining defaults". Adding
initial: :open fixes it. My reading is that stacking a rails state machine gem on an enum column
gets you two vocabularies, two sets of predicates, one set of bangs that bypasses the machine, and a
default you have to declare twice. Pick the machine or pick the enum. AASM's no_direct_assignment:
true is the cleaner version of the same intent, and it does what it says:
AASM::NoDirectAssignmentError: direct assignment of AASM column has been disabled
update! -> AASM::NoDirectAssignmentError: direct assignment of AASM column has been disabled
Though note what it still cannot see: Ticket.where(id: t.id).update_all(state: "open") went
through untouched.
Statesman moves the state out of the row
Statesman is a different shape from the other two, and the difference is the fourth guarantee. There is no state column. The current state is the newest row of a transitions table:
create_table :ticket_transitions do |t|
t.string :to_state, null: false
t.jsonb :metadata, default: {}
t.integer :sort_key, null: false
t.bigint :ticket_id, null: false
t.boolean :most_recent, null: false
t.timestamps null: false
end
add_index :ticket_transitions, %i[ticket_id sort_key], unique: true
add_index :ticket_transitions, %i[ticket_id most_recent], unique: true, where: "most_recent"
Two hops on one ticket, with metadata attached to each:
sort_key=10 to=triaged most_recent=false metadata={"actor" => "admin@example.com"} at=2026-09-24T14:25:45Z
sort_key=20 to=closed most_recent=true metadata={"actor" => "admin@example.com", "reason" => "duplicate"} at=2026-09-24T14:25:45Z
history: ["triaged", "closed"]
Ticket.in_state(:closed).count = 1
tickets.state column is still: "open"
That last line is the design. My scratch table happened to have a state column and statesman never
looked at it. sort_key climbs by ten, from
lib/statesman/adapters/active_record.rb:274: (last && (last.sort_key + 10)) || 10, leaving gaps to
insert into if you ever have to repair a history by hand.
What you get for that schema is the audit trail as a byproduct rather than as a second system. Who
closed this ticket, when, and why, is a row, not a line in a log file that rotated. Building the same
thing beside AASM means a callback writing to your own transitions table, which is perhaps fifteen
lines, and then remembering that update_column does not fire callbacks, and then discovering that
the callback ordering problem from three sections up applies to your audit row too.
One rough edge worth knowing before you read a production backtrace. Statesman::GuardFailedError
interpolates the guard's declaration, not the record's actual states, because callback.rb:17 stores
@to = Array(options[:to]) and @from stays nil when you declare only to::
Statesman::GuardFailedError: Guard on transition from: '' to '["triaged"]' returned false
An empty from there does not mean the record had no state. It means nobody wrote one in the guard.
Two workers, one ticket
Here is the failure none of the three gems prevents by default, and it is the one that actually costs
money. Two threads, two connections, each loading the same open ticket before either commits, each
firing triage!:
thread 0: triage! succeeded
thread 1: triage! succeeded
side effect ran 2 times for one ticket
final state: triaged
Both won. The final state is correct and the after hook ran twice, so the email went out twice, or
the charge did. Every in-memory guard in this post has the same hole, including the validation
version, because both objects read open before either wrote.
AASM has the switch, off by default at lib/aasm/base.rb:38. Declaring aasm column: :state,
requires_lock: true wraps the event in a transaction and a SELECT ... FOR UPDATE, and the second
thread re-reads the row inside the lock:
thread 0: triage! succeeded
thread 1: AASM::InvalidTransition: Event 'triage' cannot transition from 'triaged'.
side effect ran 1 times for one ticket
Statesman gets there by a different route, and gets there whether you asked or not, because its
generated migration ships a unique index on (parent_id, sort_key):
thread 0: transition_to! succeeded
thread 1: Statesman::TransitionConflictError: PG::UniqueViolation: ERROR: duplicate key value violates unique constraint "idx_sort"
DETAIL: Key (ticket_id, sort_key)=(1, 10) already exists.
transition rows: 1
Note that the non-bang transition_to will not save you from this one. machine.rb:334 rescues
exactly TransitionFailedError and GuardFailedError, so a conflict still raises out of the form
that otherwise returns false.
Without any gem, the same guarantee is a conditional update, and it is four lines:
def claim!(to)
raise ArgumentError, "#{status} -> #{to} is not a transition" unless TRANSITIONS.fetch(status).include?(to.to_s)
changed = self.class.where(id: id, status: status).update_all(status: self.class.statuses.fetch(to.to_s))
reload
changed == 1
end
UPDATE tickets SET status = 1 WHERE id = 1 AND status = 0 returns the number of rows it touched,
and exactly one caller gets a 1:
thread 0: claimed the transition
thread 1: lost the race, row is now triaged
side effect ran 1 time(s)
Put the side effect behind that boolean and it fires once. This is the piece I would write before I would install anything, because a state machine that permits the right moves and still performs them twice has solved the cosmetic half of the problem.
The guard that survives a psql session
Everything above lives in Ruby, and update_all beat all of it. The one layer that does not care
which process is writing is a trigger. Four legal moves, expressed as pairs:
CREATE OR REPLACE FUNCTION ticket_state_guard() RETURNS trigger AS $$
BEGIN
IF NEW.status IS DISTINCT FROM OLD.status
AND (OLD.status, NEW.status) NOT IN ((0,1), (1,2), (2,3), (2,1)) THEN
RAISE EXCEPTION 'illegal ticket transition % -> %', OLD.status, NEW.status
USING ERRCODE = 'check_violation';
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
A check constraint cannot do this, because a check constraint sees one row and a transition is two
versions of one row. OLD only exists in a trigger. With that installed, the call that defeated every
Ruby guard in this post:
legal hop -> triaged
ActiveRecord::CheckViolation: PG::CheckViolation: ERROR: illegal ticket transition 1 -> 3
sqlstate 23514
row unchanged: triaged
Raising with ERRCODE = 'check_violation' is deliberate: SQLSTATE 23514 is what Active Record maps to
ActiveRecord::CheckViolation, so your application rescues a named Rails exception rather than
pattern-matching an error string. And the same statement from a psql prompt, with no Rails in the
picture at all:
ERROR: illegal ticket transition 1 -> 3
CONTEXT: PL/pgSQL function ticket_state_guard() line 5 at RAISE
The cost is honest and it is real: the transition table now exists in two places, migrations and Ruby, and they can drift. I would reach for this only where the column is money or access, where a second service writes the same table, or where a data fix run at 2am is a plausible way to corrupt it. For a support ticket, no.
Which of these gems is alive
Naming a dead gem is the usual way a post like this ages badly, so, on 24 September 2026, from the RubyGems API:
| gem | latest | released | total downloads |
|---|---|---|---|
| aasm | 6.0.0 | 5 July 2026 | 106.3M |
| state_machines | 0.202.0 | 18 July 2026 | 183.8M |
| state_machines-activerecord | 0.200.0 | 12 June 2026 | 66.9M |
| statesman | 13.3.0 | 4 August 2026 | 6.8M |
| workflow | 3.1.1 | 12 June 2024 | 8.8M |
Four of the five are actively released. The aasm gem shipped a major version in July whose changelog
is five lines long, two of which are "Stopped support for Ruby v2" and "Stopped support for Ruby on
Rails v6". state_machines-activerecord 0.200.0's gemspec sets required_ruby_version >= 3.2 and
depends on activerecord >= 7.2, so it is no help at all on an older application. The statesman gem
is on its thirteenth major version, and 13.2.0 and 13.3.0 are five days apart.
workflow is the one to be careful about. No release since June 2024, though its repository still saw a push in October 2025, so "unmaintained" would be too strong and "not moving" is accurate. Nothing in it is unavailable elsewhere, and there is no reason to start there in 2026.
The version numbering on state_machines deserves a warning of its own. The published sequence runs
0.100.4 in October 2025, 0.101.0 in March 2026, then 0.200.0, 0.201.0 and 0.202.0 between June and
July. A minor bump of one hundred is not semantic versioning, the changelog does not explain it, and
~> 0.2 does not mean on this gem what the equivalent would mean on a 1.x one. Pin the exact
version.
The line I would draw
Take the position plainly: for two or three states with no side effects, write the enum and the transition hash. For anything with named events that people talk about in standup, and especially for anything where the question "who moved this and when" will be asked by a customer, install something.
The hash is right when the states are a property of the record. Open and closed on a support ticket is
a property. Draft and published is a property. Nobody will ever ask when a post stopped being a draft,
and if they do, updated_at is close enough.
A gem earns its place when the states are a process. A refund that goes requested, approved, sent,
settled, failed, with a different team touching each hop and money at the end, is a process. You will
be asked who approved it. You will be asked why it sat in sent for three days. Statesman answers
both out of a table you did not have to design, and that is the single clearest reason in this post to
add a dependency.
Between those two, pick AASM if you want the machine on the model and the state in the row, which is
the familiar shape and the one your next hire has seen. Pick state_machines if you want state_paths
and a graph you can assert on in specs, and if you are on Rails 7.2 or newer.
What would change my mind on the hash: a transition option on enum itself, something like
enum :status, {...}, transitions: { open: [:triaged] }. The twenty-line version would stop being
twenty lines and start being zero, the threshold for reaching for a gem would move up to callbacks
and audit, and two thirds of this post would be obsolete. Nothing in the Rails 8.1 changelog points
that way, so it is a wish rather than a forecast.
What the boilerplate does with its own lifecycles
The LaunchKit product has no state machine gem. grep -icE "aasm|state_machine|statesman|workflow"
Gemfile returns 0, and the two models with a lifecycle are written by hand, differently, on purpose.
SupportTicket is the small case in full:
STATUSES = %w[open closed].freeze
validates :status, inclusion: { in: STATUSES }
def open? = status == "open"
def closed? = status == "closed"
def close! = update!(status: "closed")
No enum, no transition table, because with two states the table would be {"open" => ["closed"],
"closed" => []} and the only illegal move is reopening, which the admin console has no button for.
Admin::SupportTicketsController#close calls close! and redirects. That is the whole lifecycle.
Subscription is the case where even the vocabulary is somebody else's, and the model says so in a
comment rather than in code:
# `status` mirrors Stripe's own subscription status. We store it as a plain string
# (not a Rails enum) on purpose: Stripe owns this vocabulary and may introduce new
# values, and an enum would raise on anything it doesn't know about.
LIVE_STATUSES = %w[trialing active].freeze
A state machine over a webhook payload is a mistake of the same family as an enum over one: Stripe decides both the values and the transitions, your machine is a guess at their diagram, and the first status they add takes down your webhook handler instead of your billing page. Mirror the field, scope on the subset you care about, and keep the machine for states you own.
What this post leaves out
Nested states are absent. None of the three gems here models a state that is itself a machine, and
none of their READMEs mentions one, so if that is what you have you are looking at a different tool
and probably a different architecture. state_machines does have something called parallel events,
which is a different feature: fire_events(:shift_down, :enable_alarm) fires one event on each of
two separate machines declared on the same object, all or nothing, and raises
StateMachines::InvalidParallelTransition if either one cannot run.
Also absent: state_machines' StdioRenderer and the separate state_machines-graphviz gem, which draw
the machine you declared and are the fastest way to find a state you cannot reach; Solid Queue's own
job lifecycle, which is a state machine with a scheduler attached and deserves its own post next to
Solid Queue against Sidekiq; and event sourcing, which looks like
statesman's transitions table from a distance and is a different commitment entirely, because there
the events are the data and the state is the projection.
Nothing here was measured for speed. Every one of these designs is one UPDATE or one INSERT per
transition, and the difference between them is not in the query plan.
Comments
No comments yet. Be the first.