Fields and Elements

How to access struct fields and collection elements in Rust using dot notation and indexing, including borrowing rules and common pitfalls

Field access and element indexing are two fundamental ways to reach inside composite data in Rust. Both are expressions that evaluate to a value or a memory location (a place), which means you can use them on the right-hand side of an assignment, pass them to functions, or chain them with other operations. Understanding how they interact with Rust’s ownership and borrowing system is essential for writing correct, idiomatic code.

Field Access Expressions

A field access expression uses the dot operator (.) to extract a named or numbered component from a struct, tuple, or enum variant. The syntax is expression.field_name or expression.index_number. Because this is an expression, it always produces a value or place—no semicolon is needed unless you want to turn the whole thing into a statement and discard the result.

Field access is not a function call:

Despite the dot, field access does not involve a function call or method dispatch. The compiler resolves the field name to a specific memory offset at compile time. This makes field access extremely fast, with no runtime overhead.

Named fields in structs

When you define a struct with named fields, you access each field by its identifier:

struct Point {
    x: f64,
    y: f64,
}
let p = Point { x: 3.0, y: 4.0 };
let x_coord = p.x;       // reads the field
let y_coord = p.y;

Here p.x is a place expression. If p is owned and the field’s type implements Copy (like f64), the value is copied. If the type is not Copy, accessing a field by value moves the field out of the struct, leaving the struct partially moved—something the compiler will prevent you from using unsafely.

Moving a field from an owned struct:

If a field’s type does not implement Copy, moving it makes the original struct unusable unless the remaining fields are still accessible. After a move, you cannot use the moved-from field and you cannot use the whole struct if any moved field is needed for a later operation. This can lead to compiler errors like use of partially moved value.

You can also access fields through a reference without moving:

let p_ref = &p;
println!("x is {}", p_ref.x);

When you access a field through a shared reference (&), the result is a shared reference to the field. Through a mutable reference (&mut), you get a mutable reference, allowing you to modify the field in place:

let mut p = Point { x: 0.0, y: 0.0 };
let p_mut = &mut p;
p_mut.x = 5.0;   // modifies the x field through the mutable reference

Tuple struct fields

Tuple structs and plain tuples use numeric indexes instead of names. The syntax is expression.0, expression.1, and so on:

struct Color(u8, u8, u8);
let red = Color(255, 0, 0);
let r = red.0;
let g = red.1;
let b = red.2;

The same borrowing and moving rules apply. A tuple with three elements can be indexed from 0 to 2—accessing red.3 causes a compile-time error because the field index is out of bounds.

Tuple field indices must be valid at compile time:

Unlike array indexing, tuple field access is checked by the compiler. There is no runtime penalty, but you must use an index that actually exists in the type. For example, let x = (1, 2); let y = x.2; will not compile because a 2‑element tuple only has fields 0 and 1.

Auto-dereferencing and borrowing during field access

Rust automatically inserts as many dereferences as necessary to reach a field. This means you can use the same dot notation whether you hold a value directly, through a reference, or through a smart pointer like Box or Rc. The compiler applies the Deref trait repeatedly until it finds a type that contains the requested field.

struct Inner {
    value: i32,
}
let boxed = Box::new(Inner { value: 42 });
// .value works directly, no explicit dereference needed
println!("{}", boxed.value);

This auto-deref mechanism is one of the reasons field access feels uniform across different ownership wrappers. It only applies to field access, not to indexing (which relies on the Index trait, not Deref).

Auto-deref works transparently:

If your code compiles when accessing a field through a Box, Rc, Arc, or any other type that implements Deref, the compiler has successfully resolved the field. The resulting access borrows or moves the inner value according to the usual rules, as if you had written (*boxed).value manually.

Privacy and visibility

Field access respects Rust’s visibility rules. If a struct field is declared without pub in a different module, trying to read or write it from outside that module is a compile-time error. This is a deliberate design: field access is the only way to interact with a struct’s data directly, so privacy controls are enforced here.

mod outer {
    pub struct Person {
        pub name: String,
        age: u8,   // private field
    }
}
let p = outer::Person { name: "Alice".to_string(), age: 30 };
println!("{}", p.name); // OK
// println!("{}", p.age); // ERROR: field `age` is private

This privacy check happens purely at compile time and has no runtime cost.

Element Access (Indexing) Expressions

Indexing expressions use square brackets ([]) and are written as expression[index]. Unlike field access, which resolves a fixed name or number at compile time, the index is an arbitrary expression evaluated at runtime. Under the hood, indexing calls the index method of the std::ops::Index trait (or index_mut of IndexMut for mutable access).

Indexing arrays, slices, and vectors

The most common targets for indexing are arrays, slices, and Vec. The index type is usually usize, and the operation returns a reference to the element:

let arr = [10, 20, 30];
let second = arr[1];       // second is i32 (copy of 20), because i32 is Copy
let third_ref = &arr[2];   // third_ref is &i32
let mut v = vec![1, 2, 3];
v[0] = 10;                 // calls IndexMut, requires v to be mutable

When you write arr[1], Rust actually calls *arr.index(1) if the type is Copy, or it binds the reference if you assign to a variable that expects a reference. The assignment v[0] = 10 calls *v.index_mut(0) = 10 internally.

Out-of-bounds indexing panics at runtime:

Indexing with an out-of-bounds value causes a panic that terminates the program (or the current thread). Rust does not return an Option or Result for subscript access. Use the .get() method on slices and vectors to safely obtain an Option<&T> without panicking.

The Index and IndexMut traits in action

The indexing expression is extensible. Any type can implement Index<Idx> for some key type Idx. For example, HashMap implements Index<&K> to retrieve a value by key, panicking if the key is missing. The signature looks like:

fn index(&self, index: Idx) -> &Self::Output;

The trait is designed to return a reference, not an owned value. This allows indexing to return a place that can be further read or assigned. For Vec<T>, Output is T, so the method returns &T.

You can implement custom indexing for your own types to provide a more natural access pattern. For instance, a matrix type might accept a (usize, usize) tuple as an index.

Range indexing produces slices

When you use a range as the index—like 0..2 or ..=3—the compiler desugars the call to index with a range type. For arrays, slices, and vectors, this returns a slice (&[T]) rather than a single element.

let data = [1, 2, 3, 4, 5];
let slice = &data[1..3];   // slice: &[2, 3]

This is a borrowing operation: the resulting slice borrows from the original collection. Mutating the original while the slice exists is prevented by the borrow checker unless you use interior mutability.

Range indexing can panic too:

If the start or end of the range exceeds the collection's length, or if the range is out of order (like 3..1), the program panics at runtime. The same safe alternatives (.get()) accept ranges and return Option<&[T]>.

Strings and indexing: why direct byte indexing isn’t allowed

Rust’s String and &str do not implement Index<usize>. The reason is safety and correctness: a String is a UTF-8 encoded sequence of bytes, and indexing by a single byte position might land in the middle of a multi-byte character, which would be meaningless. Rust chooses to prevent this operation at the type level rather than risk subtle runtime panics or corruption.

Attempting to write "hello"[0] results in a compiler error: the type 'str' cannot be indexed by 'usize'. Instead, you can work with characters or bytes explicitly:

let s = "Rust";
let chars: Vec<char> = s.chars().collect();
let first_char = chars[0];          // 'R'
let bytes = s.as_bytes();
let first_byte = bytes[0];          // 82 (b'R')

If you absolutely need a substring by byte range, you can slice with a range like &s[0..1], but this will panic if the range does not align with character boundaries. For example, &"é"[0..1] panics because the é character is two bytes wide. Use the str::get method or character‑aware slicing if you cannot guarantee the boundary.

Slicing a string can panic on character boundaries:

&s[0..1] panics when the byte range doesn’t form a valid UTF‑8 boundary. This is a common source of crashes when naïvely slicing strings that contain non‑ASCII text. Always prefer .chars(), .get(..), or dedicated string manipulation methods for safe substring extraction.

Custom indexing with your own types

Implementing Index for a user‑defined type makes the bracket syntax available for that type. This can improve readability when the type acts like a collection.

use std::ops::Index;
struct Week {
    days: [&'static str; 7],
}
impl Index<usize> for Week {
    type Output = str;
    fn index(&self, index: usize) -> &Self::Output {
        self.days[index]
    }
}
let week = Week { days: ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"] };
println!("{}", week[0]); // prints "Mon"

The same pattern works for IndexMut if you need to modify the value at a given index.

Custom Index implementations work like built-in collections:

Once you implement Index, users of your type can use the same bracket syntax they already know from arrays and vectors. Just be mindful that the trait’s contract is to panic on invalid indices for consistency with the standard library.

How Field Access and Indexing Differ

While both expressions reach inside a value, the mechanisms and guarantees are different.

AspectField Access (.)Indexing ([])
Resolution timeCompile timeRuntime
Failure modeCompile-time error if the field doesn’t exist or is privatePanic at runtime if the index is out of bounds (unless a safe method is used)
OverloadingNot directly possible; relies on Deref for auto-derefPossible via Index/IndexMut traits
Memory locationAlways yields a place (memory location)Yields a place because Index returns a reference
Typical useAccessing known fields of structs, tuples, and enum variantsAccessing elements of collections with a dynamic position

The most practical takeaway is that field names are part of the type and are verified before your program runs, while indices are values that only the running program can check. This difference influences how you approach error handling: field access errors are caught early, indexing errors must be guarded or avoided with safe alternatives like .get().

Common Mistakes and How to Avoid Them

  • Moving a field out of an owned struct when the field isn’t Copy: Prefer borrowing via a reference or using methods that return references. If you need owned access, consider destructuring the struct with let Struct { field, .. } = value; to move only the necessary pieces.
  • Indexing an array or vector with an index that might be out of bounds: Use .get() to receive an Option and handle the None case instead of assuming the index is valid.
  • Trying to index a String or &str with a single number: Understand that strings are not simple arrays of characters. Use .chars() or .get(..) for safe byte‑range slicing.
  • Forgetting that indexing returns a reference, causing unexpected borrows: The expression let x = vec[0]; copies the value if the element type is Copy because Index returns &T and the * dereference copies. If the type is not Copy, you’ll get a reference unless you explicitly dereference or clone. Be explicit about whether you want a reference or an owned clone.

Field access on enums requires pattern matching:

You cannot write my_option.some_field on an Option<SomeStruct>. Field access works only on the concrete struct or tuple, not on an enum that wraps it. Use if let or match to extract the inner value first.

Summary

Field access and indexing are the primary ways to retrieve data from composite types in Rust. Field access uses the dot and is entirely checked at compile time, giving you zero-cost safety for accessing named or numbered components. Indexing uses square brackets and relies on the Index/IndexMut traits, making it flexible enough to work with any collection-like type but requiring runtime bounds checking.

The key insight to carry forward is that both operations evaluate to places—memory locations you can read from or write to—which integrates them seamlessly with Rust’s borrowing rules. Once you understand when to use a field name, when to use an index, and how to handle the failure modes of each, you’ll be able to manipulate data structures confidently.