Skip to content
ExponaBLOG
RUST PROGRAMMING · DAY 096 / 100

Rust references vs pointers: what developers should know

Safe references and raw pointers.

Expona Admin8 min read

Understanding Rust References vs Pointers: What Developers Should Know

When you transition from languages like C or C++ to Rust, one of the first concepts that trips up experienced developers is how Rust treats references and pointers differently. In most systems languages, a pointer is a pointer—you dereference it and hope for the best. Rust, however, draws a sharp distinction between safe references and raw pointers, enforcing strict rules at compile time to prevent entire categories of bugs. Understanding this distinction is fundamental to writing Rust that compiles, performs, and actually solves problems reliably.

References Are Always Valid

In Rust, a reference is a borrowed pointer that comes with compile-time guarantees. When you create a reference, the Rust borrow checker verifies that the referenced data outlives the reference itself. This means you can never dereference a reference to data that has been freed or moved out of scope—the compiler simply won't let you.

Let's walk through a practical example. Suppose you're building a user profile service where you need to pass user data to multiple functions without transferring ownership:

rust
struct User {
    id: u64,
    name: String,
    email: String,
}

fn log_user(user: &User) {
    println!("Logging user: {} ({})", user.name, user.email);
}

fn validate_email(user: &User) -> bool {
    user.email.contains('@')
}

fn main() {
    let user = User {
        id: 1,
        name: "Alice".to_string(),
        email: "alice@example.com".to_string(),
    };

    log_user(&user);
    if validate_email(&user) {
        println!("Email is valid");
    }
    // user is still available here
    println!("User {} still exists", user.name);
}

In this example, &user creates an immutable reference. Both log_user and validate_email borrow the user temporarily. The borrow checker ensures that user is not moved or dropped while these references are active. If you tried to move user inside log_user or drop it mid-borrow, the compiler catches it immediately.

Mutable references follow the same principle but with stricter rules. You can have either multiple immutable references or one mutable reference at a time—never both.

rust
fn update_email(user: &mut User, new_email: String) {
    user.email = new_email;
}

fn main() {
    let mut user = User {
        id: 1,
        name: "Alice".to_string(),
        email: "alice@example.com".to_string(),
    };

    update_email(&mut user, "newemail@example.com".to_string());
    println!("Updated email: {}", user.email);
}

The key guarantee: as long as you compile successfully, references in Rust are always valid. You'll never encounter use-after-free, null pointer dereference, or data races caused by references alone.

Raw Pointers Need Unsafe to Dereference

Raw pointers exist in Rust for situations where the borrow checker's rules are too restrictive. They look like C pointers—*const T for immutable pointers and *mut T for mutable ones—and you can create them from references or memory addresses. The critical difference: dereferencing a raw pointer requires an unsafe block, and the burden of correctness shifts entirely to you.

Consider a scenario where you're working with a low-level memory allocator or interfacing with C libraries that return opaque pointers:

rust
extern "C" {
    fn allocate_buffer(size: usize) -> *mut u8;
    fn free_buffer(ptr: *mut u8);
}

fn process_external_data() {
    let ptr = unsafe {
        allocate_buffer(1024)
    };

    // This is unsafe territory
    unsafe {
        if !ptr.is_null() {
            *ptr = 42; // Write to the buffer
            println!("Wrote value: {}", *ptr);
        }
    }

    unsafe {
        free_buffer(ptr);
    }
}

Notice the unsafe blocks. Inside them, you can:

  • Dereference raw pointers without the compiler verifying they're valid
  • Call unsafe functions
  • Mutate global static state
  • Implement unsafe traits

Raw pointers don't track lifetimes, don't enforce exclusivity rules, and can be null. They're tools for specific jobs: FFI (foreign function interfaces), low-level memory manipulation, and performance-critical code where you need escape hatches from the borrow checker. They're not defaults—they're last resorts.

Prefer References Everywhere

In nearly all application-level Rust code, you should reach for references first. They're safer, they compose cleanly with the borrow checker, and they guide you toward better API design. References force you to think about data ownership, which often leads to clearer code.

Let's say you're building a request handler for an API service. Here's how references lead to composable, testable code:

rust
struct Request {
    method: String,
    path: String,
    body: String,
}

struct Response {
    status: u16,
    body: String,
}

fn authenticate(req: &Request) -> Result {
    // Check auth headers, etc.
    Ok(req.path != "/admin" || req.body.contains("token"))
}

fn process_request(req: &Request) -> Result {
    authenticate(req)?;
    
    Ok(Response {
        status: 200,
        body: format!("Processed: {}", req.path),
    })
}

fn handle_batch(requests: &[Request]) -> Vec> {
    requests.iter().map(|req| process_request(req)).collect()
}

fn main() {
    let requests = vec![
        Request {
            method: "GET".to_string(),
            path: "/data".to_string(),
            body: String::new(),
        },
    ];

    let responses = handle_batch(&requests);
    for resp in responses {
        match resp {
            Ok(r) => println!("Status: {}", r.status),
            Err(e) => println!("Error: {}", e),
        }
    }
}

Notice how:

  • authenticate and process_request borrow requests immutably—the caller retains ownership
  • handle_batch works with slices of requests, making it flexible and testable
  • No allocations or copying occurs beyond what's needed for responses
  • The data flow is explicit: callers own data, functions borrow temporarily

This pattern scales across microservices, command-line tools, and embedded systems. References are the backbone of idiomatic Rust.

Smart Pointers Own Their Data

Smart pointers occupy a middle ground. They're wrapper types that manage heap allocation and can implement custom behavior when data is dropped. The most common are Box<T>, Rc<T>, and Arc<T>. Unlike references, smart pointers own their data—when the smart pointer is dropped, the data it manages is deallocated.

Use Box<T> when you need heap allocation and single ownership:

rust
struct Node {
    value: i32,
    next: Option>,
}

fn main() {
    let node = Box::new(Node {
        value: 42,
        next: Some(Box::new(Node {
            value: 43,
            next: None,
        })),
    });

    println!("Node value: {}", node.value);
    // When node goes out of scope, the entire linked list is freed
}

Use Rc<T> (reference counting) when multiple parts of your single-threaded program need shared ownership:

rust
use std::rc::Rc;

struct Config {
    database_url: String,
    api_key: String,
}

fn main() {
    let config = Rc::new(Config {
        database_url: "postgres://...".to_string(),
        api_key: "secret".to_string(),
    });

    let config_clone1 = config.clone();
    let config_clone2 = config.clone();

    println!("DB: {}", config_clone1.database_url);
    println!("DB: {}", config_clone2.database_url);
    // config and both clones share the same underlying data
    // When the last clone is dropped, Config is deallocated
}

Use Arc<T> for thread-safe shared ownership in multi-threaded contexts:

rust
use std::sync::Arc;
use std::thread;

fn main() {
    let counter = Arc::new(0);

    let c1 = counter.clone();
    let handle = thread::spawn(move || {
        println!("Counter from thread: {}", c1);
    });

    println!("Counter from main: {}", counter);
    handle.join().unwrap();
}

The pattern is clear: smart pointers manage memory, enforce ownership at runtime (for Rc and Arc) or at compile time (for Box), and give you flexibility when the borrow checker alone isn't sufficient.

Common Mistakes

Mixing lifetimes carelessly: New developers sometimes create references they think outlive their referents. The compiler catches this, but the error messages can feel cryptic. Always ask: "How long does this data live, and how long does this reference need to be valid?" Often, restructuring your code to pass data by value or use smart pointers solves the problem cleanly.

Reaching for unsafe too early: It's tempting to wrap code in unsafe to bypass the borrow checker. Resist that urge. If you find yourself writing a lot of unsafe code, step back. Usually, there's a better design that works with Rust's rules, not against them.

Assuming raw pointers are faster: They're not. Raw pointers bypass compile-time checks, but they don't run faster than references. LLVM optimizes Rust references extremely well. Use unsafe only when absolutely necessary for FFI or when profiling proves it matters.

Forgetting that references don't own data: You can't store a reference in a struct unless it has an explicit lifetime parameter. This is a feature, not a bug—it prevents dangling pointers at compile time. When in doubt, use a smart pointer instead.

Key Takeaways

  • References in Rust are guaranteed valid by the borrow checker; they're always your first choice for borrowing data.
  • Raw pointers exist for low-level work—FFI, allocators, and unsafe systems code—but require explicit unsafe blocks and manual verification.
  • Smart pointers like Box, Rc, and Arc own their data and provide flexible ownership semantics when single references don't fit the problem.
  • Prefer references and smart pointers over raw pointers in application code; unsafe is a tool, not a default.
  • The distinction between safe and unsafe forces you to write clearer APIs and reason explicitly about memory and concurrency.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles