Structs
A struct (short for “structure”) is a custom data type that lets you group several related values together under one name, similar to a class without inheritance, a record, or a plain object in other languages. Structs are the primary way Rust programs model real-world data — a User, a Point, a Rectangle — and they interact directly with ownership: every struct field follows the exact same move and borrow rules as a standalone variable. Understanding structs means understanding how ownership scales from single values to composite data.
Overview: How Structs Work
A struct is a blueprint. Defining struct User { ... } does not create any data — it only describes the shape: what fields exist and what type each one holds. No memory is allocated and nothing is stored until you write a struct literal that creates an actual instance, e.g. User { username: ..., email: ... }. Think of the struct definition as an architect’s floor plan and each instance as a specific house built from that plan.
Under the hood, a struct’s own memory is just its fields laid out one after another (the compiler may reorder them for alignment, but conceptually it is contiguous). If a field is a heap-owning type like String or Vec<T>, the struct itself stores only the small “handle” (pointer, length, capacity) — the actual heap buffer lives elsewhere and is owned indirectly through that field. This matters for ownership: the struct instance owns its fields, and when the instance goes out of scope, Rust drops each field in turn, which is how a String field’s heap buffer gets freed automatically with no garbage collector.
Ownership also means a struct instance follows the same move rules as any other value. If a struct doesn’t implement the special Copy trait (and most structs with a String, Vec<T>, or other owning field cannot), assigning one variable holding it to another moves the whole struct — all its fields go with it, and the original variable becomes invalid. This is not a special case you need to memorize separately from ownership basics; it’s the same rule applied to a bigger value.
Syntax
Rust has three kinds of structs:
| Kind | Form | When to use |
|---|---|---|
| Named-field struct | struct User { username: String, active: bool } |
Most common; fields are accessed by name (user.username). |
| Tuple struct | struct Color(i32, i32, i32); |
Fields have types but no names; accessed by index (color.0). Good for a lightweight wrapper around one or a few values. |
| Unit-like struct | struct Marker; |
No fields at all; useful when you need a type to implement a trait on but store no data. |
// Regular struct with named fields
struct User {
username: String,
email: String,
active: bool,
}
// Tuple struct: fields have types but no names
struct Color(i32, i32, i32);
// Unit-like struct: has no fields at all
struct Marker;
To attach behavior, you write a separate impl (“implementation”) block. Functions defined inside it that take &self, &mut self, or self as their first parameter are methods, called with dot syntax (instance.method()). Functions that don’t take self are associated functions, called with :: (Type::function()) — this is how constructors like String::from and, conventionally, a struct’s own new function are written.
Examples
Example 1: Defining and instantiating a struct
struct User {
username: String,
email: String,
active: bool,
sign_in_count: u64,
}
fn main() {
let user1 = User {
username: String::from("alice"),
email: String::from("alice@example.com"),
active: true,
sign_in_count: 1,
};
println!("Username: {}", user1.username);
println!("Email: {}", user1.email);
println!("Active: {}", user1.active);
println!("Sign-in count: {}", user1.sign_in_count);
}
Username: alice
Email: alice@example.com
Active: true
Sign-in count: 1
We build one User instance by naming every field. Notice the fields use String::from(...) rather than string literals directly — a struct field typed String needs to own its data, and a &str literal is borrowed, not owned, so String::from converts it into an owned, heap-allocated string the struct can hold onto for its whole lifetime.
Example 2: Field init shorthand and struct update syntax
struct User {
username: String,
email: String,
active: bool,
sign_in_count: u64,
}
fn build_user(username: String, email: String) -> User {
User {
username,
email,
active: true,
sign_in_count: 1,
}
}
fn main() {
let user1 = build_user(String::from("bob"), String::from("bob@example.com"));
let user2 = User {
email: String::from("carol@example.com"),
..user1
};
println!("user2 username: {}", user2.username);
println!("user2 email: {}", user2.email);
}
user2 username: bob
user2 email: carol@example.com
build_user uses field init shorthand: when a parameter name matches a field name exactly, you can write just username instead of username: username. user2 is built with struct update syntax (..user1), which fills in every field we didn’t specify by taking it from user1. Because username is a String (not Copy), that field is moved out of user1 into user2 — after this point user1 as a whole can no longer be used (only its still-Copy fields would technically survive a partial move, but the compiler treats the value as consumed for our purposes here), which is exactly why we don’t reference user1 again after building user2.
Example 3: Methods and associated functions with impl
struct Rectangle {
width: u32,
height: u32,
}
impl Rectangle {
fn new(width: u32, height: u32) -> Rectangle {
Rectangle { width, height }
}
fn area(&self) -> u32 {
self.width * self.height
}
fn can_hold(&self, other: &Rectangle) -> bool {
self.width > other.width && self.height > other.height
}
}
fn main() {
let rect1 = Rectangle::new(30, 50);
let rect2 = Rectangle::new(10, 40);
println!("Area of rect1: {}", rect1.area());
println!("Can rect1 hold rect2? {}", rect1.can_hold(&rect2));
}
Area of rect1: 1500
Can rect1 hold rect2? true
Rectangle::new is an associated function (no self) used as a constructor, called with ::. area and can_hold are methods that borrow the instance immutably via &self — they only need to read the fields, not consume or mutate the struct, so borrowing is correct and lets us call rect1.area() and later still use rect1 again. can_hold also borrows another Rectangle via &Rectangle so it can compare without taking ownership of rect2 either.
How It Works Step by Step
#[derive(Debug)]
struct Point {
x: i32,
y: i32,
}
fn main() {
let p1 = Point { x: 1, y: 2 };
let p2 = p1; // p1 is moved into p2; Point does not implement Copy
println!("p2 = {:?}", p2);
}
p2 = Point { x: 1, y: 2 }
Trace what the compiler actually does here:
let p1 = Point { x: 1, y: 2 };allocatesPoint‘s twoi32fields on the stack and binds them top1.p1is the sole owner.let p2 = p1;copies the struct’s bytes top2‘s stack slot (this is a shallow bitwise copy at the machine level) — but becausePointdoesn’t implementCopy, Rust treats this as a move at the type-system level: ownership transfers top2, andp1is marked invalid.- Any later use of
p1(for exampleprintln!("{:?}", p1)) would fail to compile with “borrow of moved value:p1” — the compiler statically tracks which bindings are still valid, so this is caught before the program ever runs, not as a runtime crash. - When
p2goes out of scope at the end ofmain, Rust automatically drops it. SincePoint‘s fields are both plaini32s with no heap data, dropping is trivial — but for a struct holding aString, the same drop would also free thatString‘s heap buffer.
The reason a type like i32 would let you skip all this (via #[derive(Copy, Clone)] on a struct made only of Copy fields) is that duplicating a few integers is cheap and has no ownership implications — there’s no heap resource to double-free. A String field rules that out, because two owners of the same heap buffer would both try to free it, which is exactly the bug ownership exists to prevent.
Common Mistakes
Mistake 1: Forgetting mut on the instance
Struct fields are only mutable if the binding itself is declared mut — there is no per-field mutability.
struct Point {
x: i32,
y: i32,
}
fn main() {
let p = Point { x: 1, y: 2 };
// error[E0594]: cannot assign to `p.x`, as `p` is not declared as mutable
p.x = 5;
println!("{}", p.x);
}
The fix is to mark the binding mut:
struct Point {
x: i32,
y: i32,
}
fn main() {
let mut p = Point { x: 1, y: 2 };
p.x = 5;
println!("{}", p.x);
}
5
Mistake 2: Using a struct after passing it by value
Passing a non-Copy struct into a function that takes ownership (no &) moves it — the caller loses access, even though nothing looks “deleted” in the code.
struct User {
name: String,
}
fn print_user(user: User) {
println!("User: {}", user.name);
}
fn main() {
let user1 = User { name: String::from("Dana") };
print_user(user1);
// error[E0382]: borrow of moved value: `user1`
println!("{}", user1.name);
}
If the function only needs to read the data, take a reference instead of ownership:
struct User {
name: String,
}
fn print_user(user: &User) {
println!("User: {}", user.name);
}
fn main() {
let user1 = User { name: String::from("Dana") };
print_user(&user1);
println!("{}", user1.name);
}
User: Dana
Dana
A related trap is assuming let b = a; “copies” a struct the way it would for an i32. Unless every field is a Copy type and the struct derives Copy, it moves, not copies — see the Point vs. a hypothetical String-holding struct in the section above.
Best Practices
- Name struct fields for what they represent, and prefer several small, well-named structs over one struct with many loosely related fields.
- Use
Stringfor fields that must own and outlive their data, and take&strparameters in functions that only need to read a string — don’t require callers to allocate aStringjust to call your function. - Write a
newassociated function as the idiomatic constructor instead of exposing raw struct-literal construction everywhere, especially once a struct has invariants to enforce. - Prefer methods that borrow (
&self) over ones that consume (self) unless you specifically want to transform the instance into something else and prevent further use of the original. - Add
#[derive(Debug)]to structs during development so you can print them with{:?}for debugging without writing a formatter by hand. - Only derive
Copywhen every field is itselfCopyand the struct is small — it changes assignment semantics from move to duplicate, which is a real behavioral decision, not just a convenience annotation. - Use tuple structs for small, self-evidently-ordered data (like an RGB triple) and named-field structs once field meaning isn’t obvious from position alone.
Practice Exercises
- Define a
Bookstruct withtitle: String,author: String, andpages: u32. Write an associated functionBook::newand a methodsummary(&self) -> Stringthat returns a formatted string like"Title by Author (pages pages)". - Define a tuple struct
Celsius(f64)and a methodto_fahrenheit(&self) -> f64that converts usingc * 9.0 / 5.0 + 32.0. Test it withCelsius(100.0)and confirm the output is212. - Write a function
oldest<'a>(a: &'a Person, b: &'a Person) -> &'a Personfor aPerson { name: String, age: u32 }struct that returns a reference to whichever person is older. Think through why the function must borrow rather than take ownership if the caller needs to keep using bothPersonvalues afterward.
Summary
- A struct groups related fields under one named type; named-field, tuple, and unit-like structs cover different needs.
- A struct instance owns its fields; when it’s dropped, each field is dropped too, which is how owned heap data (like a
Stringfield) gets freed automatically. - Assigning or passing a struct that isn’t
Copymoves it, invalidating the original binding — the same rule that applies to any single non-Copyvalue. - Struct update syntax (
..instance) fills unspecified fields from an existing instance, and can move out of that instance’s non-Copyfields. - An
implblock adds methods (&self/&mut self/self) and associated functions (noself, called viaType::function()) to a struct. - Prefer borrowing (
&self,&str,&Tparameters) over taking ownership whenever a function only needs to read data, to avoid unnecessary moves.
