Category guides

State machines in Rails: AASM, state_machines and Statesman compared

Category guides · · Published 2026-11-07

A hand-operated railway switch with its lever, counterweight and lamp beside the track
A set of points: a track can lead in several directions, but only one at a time, and only where the rails allow it.

Almost every Rails application has a model with a status column: an order that is pending, paid, shipped or refunded; an article that is a draft, in review or published. The column is easy. The rules are the hard part: which changes are allowed, what has to happen when one occurs, and whether anyone will need to know later when and why it happened. Four approaches cover most needs, and they differ mainly in how much of that they take on.

Release figures are from RubyGems.org, November 2026. The current Rails line is 8.1.

Start with what Rails already gives you

An Active Record enum maps a column to a fixed list of values and generates predicates, bang setters and scopes:

class Order < ApplicationRecord
  enum :status, { pending: 0, paid: 1, shipped: 2, refunded: 3 }, validate: true
end

order.paid?        # predicate
order.shipped!     # sets and saves, from any state
Order.pending      # scope

Since Rails 7.1, validate: true turns an unknown value into a validation error instead of an ArgumentError, which helps with form input. What an enum does not know is the order of things: shipped! works just as well on a pending order as on a paid one. If the rules fit in a couple of guard clauses in a service object, that may be enough. Once you find yourself writing raise unless paid? in several places, a state machine gem starts paying for itself, because it puts the list of allowed transitions in one place you can read.

AASM: the compact DSL on the model

AASM (6.0.0, July 2026, MIT) started life as the acts_as_state_machine plugin by Scott Barron in 2006, one of the Rails plugin family that also produced acts_as_list. It is now a general Ruby library with adapters for Active Record, Mongoid, Sequel and others. States and events are declared inside the model:

class Order < ApplicationRecord
  include AASM

  aasm column: :status, requires_lock: true do
    state :pending, initial: true
    state :paid, :shipped, :refunded

    event :pay, after_commit: :send_receipt do
      transitions from: :pending, to: :paid, guard: :payment_captured?
    end

    event :ship do
      transitions from: :paid, to: :shipped
    end
  end
end

order.may_pay?   # guard check, no change
order.pay!       # transition and save
Order.paid       # automatic scope

The bang methods save the record inside a transaction, and requires_lock: true wraps it in with_lock, so two workers cannot both move the same order out of pending. The after_commit event callback only runs with the bang methods; calling pay and then save! yourself skips it, a detail the README spells out. The README also recommends adding the after_commit_everywhere gem, because AASM's own commit hook otherwise follows its internal transaction rather than the real database one, which matters inside nested transactions.

Version 6.0 is a real major release. According to its changelog it drops Ruby 2 and Rails 6, and makes whiny_persistence: true the default, so a bang event that fails validation now raises instead of quietly returning false. Code that checked the return value of pay! needs a look before upgrading. Other options worth knowing: no_direct_assignment: true makes order.status = "shipped" raise, closing the back door around your rules, and timestamps: true fills a paid_at column when the record enters paid.

Choose it when you want the rules next to the model, in a DSL most Rails developers have met before.

state_machines: the heir of pluginaweek's state_machine

The state_machine gem was the other big name of the Rails 3 era. When it stopped receiving releases, a community organisation picked it up under the plural name state_machines, split into a core gem and integrations. For Rails you install state_machines-activerecord (0.200.0, June 2026, MIT), which pulls in the core gem (0.202.0, July 2026) and the Active Model layer.

class Vehicle < ApplicationRecord
  state_machine :status, initial: :parked do
    before_transition parked: any - :parked, do: :check_seatbelt

    event :ignite do
      transition parked: :idling
    end

    state :first_gear do
      validates :seatbelt_on, presence: true
    end
  end
end

Vehicle.with_state(:parked)
vehicle.status_event = "ignite"; vehicle.save

Its distinctive features are state-specific behaviour, such as validations that only apply in one state, and implicit events: setting status_event and saving fires the event, which is handy when a form or an API should drive transitions without a custom controller action. The integration's README also describes automatic handling of a Rails enum declared on the same column, prefixing the state machine's methods to avoid name clashes, and scopes that return everything when passed nil, convenient for search filters.

One thing to check before adopting it: the README lists Ruby 3.2 and Rails 8.0 or later as requirements, while the gemspec of the current release accepts Active Record from 7.2. If you are still on 7.2, treat that as untested ground rather than a promise.

Choose it when transitions come from user input, or when some validations only make sense in particular states.

Statesman: transitions as rows

Statesman (13.4.0, October 2026, MIT) comes from GoCardless, a payments company, and its design shows it. The state is not a column on the model at all. Each transition is a row in a separate table, with a to_state, a sort key, a most_recent flag and optional JSON metadata; the current state is whatever the latest row says. The rules live in their own class:

class OrderStateMachine
  include Statesman::Machine

  state :pending, initial: true
  state :paid
  state :shipped
  state :refunded

  transition from: :pending, to: :paid
  transition from: :paid,    to: [:shipped, :refunded]

  guard_transition(to: :paid) { |order| order.payment_captured? }
  after_transition(to: :paid, after_commit: true) { |order, _t| ReceiptMailer.with(order:).paid.deliver_later }
end

order.state_machine.transition_to!(:paid, reason: "card")
order.state_machine.history
Order.in_state(:paid)

You pay for that with more setup: a transition model, a migration with the right unique indexes, and a state_machine method on the parent. In return you get a complete, queryable history of every change with its metadata, and duplicate transitions blocked by the database itself rather than by application code. Reading the current state costs a join; version 13.3 added an optional cached state column on the parent model to avoid it, and 13.4 added Statesman::BulkTransition to move many records at once, skipping guards and callbacks. The maintainers' compatibility policy is to follow the supported Ruby and Rails versions and bump the major version when one drops out, which explains the high version number.

Choose it when the history of states is itself business data: payments, compliance workflows, anything someone will audit.

History, side effects and other traps

Summary

OptionWhere the state livesLatest releaseBest fit
Rails enumColumn on the modelPart of RailsFew states, rules simple enough for guard clauses
AASMColumn on the model6.0.0, July 2026Rules declared on the model, locking built in
state_machines-activerecordColumn on the model0.200.0, June 2026Form- or API-driven transitions, per-state validations
StatesmanSeparate transitions table13.4.0, October 2026Workflows whose history must be kept and queried