How to Implement a Finite State Machine in Rust
You're building a network client. The code needs to track whether the connection is down, dialing, or live. You can't send a message if the socket is closed. You can't start dialing if you're already connected. A simple boolean flag isn't enough. You need a way to enforce that the system only moves between valid configurations. Rust gives you a tool that makes invalid states impossible to represent. That tool is the enum.
A finite state machine in Rust is just an enum paired with a match expression. The enum lists every possible state. The match expression defines the rules for moving between them. The compiler acts as a safety net, rejecting your code if you try to transition in a way you haven't explicitly allowed. This approach turns state management from a source of bugs into a mechanical exercise the compiler verifies.
The concept: states as data, transitions as functions
Think of a board game piece. The piece sits on a square. That square is the state. You roll dice, which is the event. The piece moves to a new square, which is the transition. The piece can't be on two squares at once. It can't jump over squares unless the rules allow it. The rules are the transition logic.
In Rust, the board squares are the variants of an enum. The dice roll is a value passed to a function. The movement is the result of a match expression. The match expression is exhaustive. If you add a new square to the board, the compiler forces you to write a rule for it. You can't forget to handle a state. The compiler catches the omission before the program runs.
This pattern relies on two Rust features. Enums let you define a closed set of values. Match expressions let you destructure those values and compute a result based on the variant. Together, they create a state machine where the type system guarantees correctness.
Minimal example
Start with a simple enum. Derive Debug and PartialEq so you can print the state and compare it in tests. Define a function that takes the current state and an action, then returns the next state.
/// Represents the lifecycle of a simple process.
#[derive(Debug, PartialEq)]
enum ProcessState {
/// The process has not started yet.
Idle,
/// The process is actively executing.
Running,
/// The process has finished or been halted.
Stopped,
}
/// Computes the next state based on the current state and an action.
/// Returns the new state, leaving the old one untouched.
fn transition(current: ProcessState, action: &str) -> ProcessState {
// Tuple pattern matches both values simultaneously.
match (current, action) {
(ProcessState::Idle, "start") => ProcessState::Running,
(ProcessState::Running, "stop") => ProcessState::Stopped,
// Default case: invalid actions are ignored.
_ => current,
}
}
fn main() {
let mut state = ProcessState::Idle;
// Transition consumes the old state and produces a new one.
state = transition(state, "start");
println!("{state:?}");
}
The transition function takes ownership of current. It returns a new ProcessState. The caller assigns the result back to state. This functional style keeps the logic pure. The function has no side effects. You can test it by passing inputs and checking outputs.
How the compiler protects you
When you add a new variant to the enum, the compiler rejects any match expression that doesn't handle it. You get E0004 (non-exhaustive patterns). This error is a feature. It forces you to update every transition function whenever the state space changes.
Imagine you add a Paused state. The compiler points to the transition function and complains. You must decide how Paused behaves. Can you pause from Running? Can you resume from Paused? The compiler won't let you ship code where Paused is a black hole. You have to write the rules.
This behavior scales. In a large codebase, adding a state triggers errors in every module that handles transitions. You can't introduce a state and forget to wire it up. The compiler acts as a regression test for your state machine.
Convention aside: Always derive Debug and PartialEq on state enums. You will want to log the state during development and assert equality in tests. Skipping these derives causes pain later.
Treat the compiler error as a to-do list. If E0004 appears, you have an incomplete state machine. Fix the match, not the error.
Realistic example: states with data
Simple enums work for flags. Real systems often need context. Rust enums can hold data in each variant. This lets you attach information to specific states without scattering variables across the codebase.
/// Tracks a network connection with retry logic and context.
#[derive(Debug)]
enum Connection {
/// No connection exists.
Disconnected,
/// Attempting to connect. Holds the retry count.
Connecting { attempts: u32 },
/// Successfully connected. Holds the session ID.
Connected { session_id: u64 },
/// Connection failed permanently.
Error { reason: String },
}
/// Events that drive the state machine.
enum Event {
Connect,
Timeout,
Success(u64),
Fail(String),
Disconnect,
}
/// Handles an event and returns the resulting connection state.
fn handle(state: Connection, event: Event) -> Connection {
match (state, event) {
// Start the connection process from scratch.
(Connection::Disconnected, Event::Connect) => {
Connection::Connecting { attempts: 1 }
}
// Retry on timeout, up to three attempts.
(Connection::Connecting { attempts }, Event::Timeout) => {
if attempts < 3 {
Connection::Connecting { attempts: attempts + 1 }
} else {
Connection::Error { reason: "Max retries exceeded".to_string() }
}
}
// Success moves to connected state with the session ID.
(Connection::Connecting { .. }, Event::Success(id)) => {
Connection::Connected { session_id: id }
}
// Disconnect resets to idle state.
(Connection::Connected { .. }, Event::Disconnect) => {
Connection::Disconnected
}
// Any other combination is ignored.
_ => state,
}
}
The Connecting variant holds an attempts counter. The Connected variant holds a session_id. The Error variant holds a message. The data lives with the state. You can't have a Disconnected connection with a session ID. The type system prevents that mistake.
The match expression extracts the data. In the Timeout arm, the pattern Connection::Connecting { attempts } binds the counter to a variable. The logic checks the counter and decides whether to retry or fail. The .. pattern in Success discards the attempts count because it's no longer relevant.
Convention aside: Keep the state enum private to the module. Expose only the handle function and the Event enum. This hides the internal structure. Callers can't construct invalid states or manipulate the state directly. They can only drive the machine through events.
Data in states turns a dumb FSM into a smart one. The compiler still checks the structure, but now you carry context without scattering variables.
Pitfalls and compiler errors
State machines in Rust are robust, but a few traps exist.
If you try to extract data from a state variant and then use the state again, you hit E0382 (use of moved value). The match expression consumes the state. You must return a new state. This enforces the functional style. You can't partially update a state and leave it half-broken. The compiler forces you to rebuild the state from scratch.
If you use a catch-all _ pattern too aggressively, you hide bugs. The catch-all is useful for ignoring invalid transitions, but it also swallows typos. If you misspell an event, the catch-all catches it silently. Prefer explicit patterns for all valid transitions. Use the catch-all only as a final safety net.
State explosion happens when you add too many states. If every combination of flags becomes a state, the enum grows unmanageable. The match expression becomes a wall of text. When this happens, consider splitting the machine. Use a sub-state machine for a complex part. Or use the State pattern with traits, where each state is a struct implementing a common interface.
Guard clauses let you add conditions without creating new states. If you have a state that depends on a counter, you can check the counter in the match arm. This keeps the state count low. However, too many guards make the match hard to read. If guards get complex, consider splitting the state.
Treat the state as immutable during the transition. Compute the next state, then replace the old one. The compiler will force this discipline.
When to use this approach
Rust offers several ways to model state. Pick the right tool based on complexity and distribution.
Use an enum-based FSM when the number of states is small and transitions are local. The match expression stays readable, and the compiler catches missing cases. This is the default choice for most state machines in Rust.
Use the State pattern with traits when transitions are complex and distributed across many modules. Each state becomes a struct implementing a trait, and the transition logic lives inside the state. This adds indirection and runtime cost, but it lets you split the code across files.
Use a library like xstatrs when you need visualization, serialization, or complex guards that make manual code too verbose. Libraries handle boilerplate and provide tools for debugging. They add a dependency, so weigh the cost against the benefit.
Reach for simple if/else chains when the logic is linear and state is just a flag. An FSM adds structure you don't need. If the system only has two states and one transition, a boolean is enough.
Counter-intuitive but true: the more states you add, the more value the enum gives you. The compiler's exhaustiveness check becomes a powerful asset as the machine grows.