State machines in Rails: AASM, state_machines and Statesman compared

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
- History is not free with AASM or state_machines. They store the current state only. If you need the timeline, either pick Statesman or add a versioning gem; our comparison of audit trail gems covers PaperTrail, Audited and Logidze.
- Side effects belong after the commit. Sending an email or charging a card in a
beforeoraftercallback that runs inside the transaction means a rollback can leave the email sent and the state unchanged. Use each gem's after-commit hook and hand slow work to a job; see our guide to background job backends. - Concurrency. Two requests can read the same state and both decide a transition is allowed. Lock the row (AASM's
requires_lock, orwith_lockaround state_machines events), or rely on Statesman's unique indexes. - Not every end state is a deletion. An archived project is often better modelled as a state than as a hidden row; our guide to soft deletes discusses when a status column says more than a
deleted_attimestamp. - Exit cost. AASM and state_machines both keep a plain string column, so moving between them is mostly a rewrite of model code. Leaving Statesman means collapsing the transition table back into a column. Run the candidates through the gem evaluation checklist before committing.
Summary
| Option | Where the state lives | Latest release | Best fit |
|---|---|---|---|
| Rails enum | Column on the model | Part of Rails | Few states, rules simple enough for guard clauses |
| AASM | Column on the model | 6.0.0, July 2026 | Rules declared on the model, locking built in |
| state_machines-activerecord | Column on the model | 0.200.0, June 2026 | Form- or API-driven transitions, per-state validations |
| Statesman | Separate transitions table | 13.4.0, October 2026 | Workflows whose history must be kept and queried |
Category guidesAudit trails and versioning
PaperTrail, Audited or Logidze: a versions table, an audits table, or a trigger-maintained log in a JSONB column.
Category guidesSoft deletes
Discard adds a discarded_at column and two scopes; Paranoia and acts_as_paranoid override destroy. Which one, and when to skip soft deletes entirely.
Category guidesBackground jobs
How to choose a background job backend for Rails: Active Job, Solid Queue (the Rails 8 default), Sidekiq, GoodJob, delayed_job and Resque, and what replaced Workling and Spawn.