LaunchKit
← All posts
· 20 min read · by The LaunchKit team · 0 views

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.

#rails #active-record

Comments

No comments yet. Be the first.

Only used to confirm and publish your comment. Never shown publicly, never shared.

Markdown: **bold**, `code`, ```fenced blocks```, > quotes, [links](url). HTML and images are not rendered.