The shape of your data decides the container
You are building a configuration parser. The file contains key-value pairs. Some values are strings, some are integers, some are booleans. You also need to represent the parser itself: it has a current line number, a buffer of raw text, and a list of parsed entries. In Python or JavaScript, you would reach for a dictionary or an object for both. Rust draws a hard line. You model the parser as a struct. You model the value types as an enum. The language forces this split because it changes how the compiler allocates memory, how you handle missing cases, and how you write the logic that consumes the data.
Structs hold a fixed set of properties
A struct is a fixed blueprint. Every instance contains the same set of fields, laid out in a predictable order. Think of a library checkout card. It always has a barcode, a due date, a borrower name, and a fine balance. The boxes never change. You might leave some blank, but the shape is rigid.
Rust enforces this distinction at the type level. You cannot accidentally treat a User struct as a Status enum, and you cannot leave a struct in an undefined state. The type system becomes a contract that guarantees shape and validity before the program runs.
/// Represents a single configuration entry with a key and a flexible value.
struct ConfigEntry {
key: String,
value: ConfigValue,
}
/// A value can be text, a number, or a list of text items.
enum ConfigValue {
Text(String),
Number(f64),
List(Vec<String>),
}
fn main() {
// Create a struct instance by filling every field.
// The compiler verifies all fields are present.
let entry = ConfigEntry {
key: String::from("timeout"),
value: ConfigValue::Number(30.5),
};
// Access struct fields with dot notation.
// The compiler knows the exact memory offset for each field.
println!("Key: {}", entry.key);
// Extract enum data using pattern matching.
// Each arm handles exactly one variant and binds its payload.
match entry.value {
ConfigValue::Text(s) => println!("Text value: {}", s),
ConfigValue::Number(n) => println!("Number value: {}", n),
ConfigValue::List(items) => println!("List has {} items", items.len()),
}
}
The struct groups related pieces of information that always travel together. The enum declares a closed set of possibilities, each carrying its own payload. You cannot construct a ConfigEntry without providing both a key and a value. You cannot create a ConfigValue without explicitly picking one of the three variants. The compiler rejects incomplete construction at compile time.
Define your data shape before you write the logic that manipulates it.
Enums hold exactly one variant at a time
An enum is a choice container. It holds exactly one variant from a predefined list at any given moment. Think of a physical radio dial. You can tune to AM, FM, or DAB. You cannot be on two bands simultaneously. Each band might carry different information. AM gives you a frequency number. FM gives you a station name and signal strength. DAB gives you a service ID and a text description. The container adapts its internal payload to match the selected variant.
Rust calls the selected option a variant. Variants can be unit variants with no data, tuple variants with positional data, or struct variants with named data. The compiler tracks which variant is active using a hidden discriminant. This tag lives alongside the payload in memory and enables safe pattern matching without runtime type checks.
/// Tracks a single network connection and its current lifecycle stage.
struct Connection {
address: String,
port: u16,
state: ConnectionState,
}
/// The connection moves through these stages sequentially.
enum ConnectionState {
Connecting,
Established { bytes_received: u64 },
Error(String),
Closed,
}
fn update_state(conn: &mut Connection, event: &str) {
// Mutate the enum variant based on external input.
// The match guarantees we handle every possible state.
match conn.state {
ConnectionState::Connecting => {
conn.state = ConnectionState::Established { bytes_received: 0 };
}
ConnectionState::Established { ref mut bytes_received } => {
*bytes_received += event.len() as u64;
}
_ => {}
}
}
The struct holds the stable identity of the connection. The enum holds the volatile state. You mutate the enum inside the struct to reflect runtime changes. The match arms destructure the variant, bind to its inner data, and allow you to update it in place. This pattern scales to parsers, UI frameworks, and game loops. You keep the static shape in a struct and the dynamic behavior in an enum.
Model state transitions as enums. The compiler will force you to acknowledge every step.
How the compiler treats them differently
Structs compile to contiguous memory. The compiler calculates the total size by adding up the size of each field, plus any padding for alignment. When you pass a struct to a function, the compiler knows exactly how many bytes to copy or how to borrow a slice of memory.
Enums compile to a tagged union. The compiler reserves space for the largest possible variant, plus a small hidden integer called a discriminant. The discriminant acts as a runtime tag that tells the program which variant is currently active. When you pattern match, the compiler checks the discriminant first, then safely extracts the payload from the correct memory offset.
This layout difference drives Rust's pattern matching guarantees. If you add a new variant to an enum, the compiler scans every match expression in your codebase and flags the ones that do not handle the new case. You get a build failure instead of a runtime panic or silent data loss. Structs do not trigger this behavior because adding a field to a struct only breaks code that directly accesses that field by name. The rest of the program continues to compile.
Trust the exhaustiveness check. It catches missing business logic before it reaches production.
Zero-sized variants and memory optimization
Not all enum variants carry data. Unit variants like Connecting or Closed in the example above take up zero bytes for their payload. The compiler still reserves space for the discriminant, but it optimizes the layout aggressively. If an enum contains only unit variants, it often compiles to a single byte or a single bitfield, depending on the number of variants and platform alignment.
This optimization matters when you store thousands of enum instances in a Vec. A Vec<ConnectionState> will consume significantly less memory than a Vec<ConfigValue> because the latter must reserve space for a String or Vec<String> in every slot. The compiler aligns the union to the largest variant, so every element in the vector gets the same allocation size. You pay the memory cost of the worst-case variant for every instance.
Plan your variant payloads carefully. Put heavy data behind references or Box when the enum lives in large collections.
Pattern matching and ownership transfer
Pattern matching does more than read data. It moves ownership. When you match on an owned enum, the compiler transfers the payload from the variant into the binding. This prevents double-free errors and eliminates the need for manual memory management.
fn process_value(val: ConfigValue) {
// The match takes ownership of `val`.
// Each arm binds the payload and moves it out of the enum.
match val {
ConfigValue::Text(s) => {
// `s` is now an owned String.
// The original enum is consumed and cannot be used again.
println!("Processing text: {}", s);
}
ConfigValue::Number(n) => {
// `n` is copied because f64 implements Copy.
// The enum is still consumed, but the number lives on.
println!("Processing number: {}", n);
}
ConfigValue::List(items) => {
// `items` is moved. The Vec is now owned by this arm.
println!("Processing list of length {}", items.len());
}
}
}
If you need to keep the enum alive after the match, you borrow it instead. Use &val in the match expression and add ref or ref mut to the bindings. The compiler tracks the borrow duration and prevents you from using the original value while a mutable reference exists. This is the same borrowing rule that applies to structs, but enums make it visible through pattern syntax.
Match on owned values when you are done with the container. Borrow when you need to inspect and reuse.
Common traps and compiler rejections
Developers coming from dynamic languages often reach for a struct with a String type field to mimic an enum. You create a Command struct with a kind: String and a payload: String. This defeats the type system. The compiler cannot verify that kind is actually "save", "load", or "exit". You will write string comparisons everywhere and risk typos that only surface at runtime.
Another trap is using bool for states that will inevitably grow. You start with is_active: bool. Six months later, you need pending, suspended, and archived. Refactoring a boolean into an enum requires touching every conditional in the codebase. Start with an enum when the domain suggests distinct categories, even if you only have two right now. The compiler will force you to handle the new cases cleanly when they arrive.
If you forget to handle a variant in a match, the compiler rejects you with E0004 (non-exhaustive patterns). You cannot bypass this with a generic catch-all unless you use _, which silently discards unhandled cases. Use _ only when you genuinely do not care about the remaining variants. Otherwise, list them explicitly.
You will also encounter E0063 (missing fields) when constructing structs. Rust requires you to name every field during initialization. You cannot pass positional arguments like User("alice", true). The language prioritizes readability and future-proofing over brevity. If a struct gains a new field in a library update, your code breaks at the call site instead of silently receiving a default value.
Treat missing field errors as a feature. They force you to acknowledge new data requirements explicitly.
Choosing between them
Use a struct when you need to group multiple related values that always exist together. Use a struct when the data represents a single entity with stable properties, like a user profile, a database row, or a geometric point. Use an enum when a value can be one of several distinct types or states, but never more than one at a time. Use an enum when you want the compiler to enforce exhaustive handling of every possibility. Reach for a boolean only when the domain is strictly binary and will never expand. Pick a tuple struct or tuple enum when you need positional syntax for performance-critical code or FFI bindings, but prefer named fields for everyday application logic.
Convention asides
The community standard for both structs and enums is to derive Debug and Clone at the top of the definition. #[derive(Debug, Clone)] saves you from writing boilerplate and makes debugging output consistent. When naming enums, use a singular noun that describes the category, like TokenType or ParseResult. When naming variants, use PascalCase and treat them as subtypes of the parent enum. The compiler treats Status::Active as a zero-sized variant, but you can still attach data to it using tuple or struct syntax. Stick to the explicit EnumName::Variant form in pattern matches. It reads like a contract and survives refactoring better than shorthand.
Let the type system carry the business rules. Write less conditional logic.