← Back to DevBytes

Memory Management in Rust: A Deep Dive

Introduction to Memory Management in Rust

Memory management in Rust is not just a feature—it's the cornerstone of the language's identity. Unlike languages that rely on garbage collectors (Java, Go, Python) or manual allocation/deallocation (C, C++), Rust introduces a third path: compile-time memory safety through its ownership system. This tutorial will take you from first principles to advanced patterns, with complete, runnable code examples at every step.

What Is Rust's Memory Management Model?

Rust manages memory through a set of rules enforced at compile time. These rules revolve around three core concepts:

There is no garbage collector scanning the heap. There is no malloc/free that you call manually (unless you explicitly use unsafe code). Instead, the compiler inserts allocation and deallocation code at precisely the right points, and it refuses to compile code that would violate memory safety.

Why Memory Management in Rust Matters

The Rust memory model matters because it solves one of the hardest problems in systems programming: how do you write high-performance software without constantly worrying about memory bugs?

These guarantees are not enforced at runtime with a performance penalty; they are enforced at compile time with zero runtime cost. This is what makes Rust suitable for operating systems, embedded devices, game engines, and browser components like Firefox's Servo engine.

Ownership: The Foundation

🚀 Deploy your AI agent in 10 minutes

Managed Hermes hosting. Zero DevOps. 100M tokens/mo included.

Try it free →

Ownership Rules

Every value in Rust is owned by a single variable. When the owner goes out of scope, the value is dropped (freed). Here are the fundamental rules:

Move Semantics

When you assign a non-Copy value to another variable or pass it to a function, ownership moves. The original variable becomes inaccessible. This prevents use-after-move bugs:

fn main() {
    let s1 = String::from("hello");
    let s2 = s1;  // Ownership moves to s2
    
    // println!("{}", s1);  // COMPILE ERROR: s1 was moved!
    println!("{}", s2);     // Works fine: s2 owns the string now
    
    let v = vec![1, 2, 3];
    take_ownership(v);
    // println!("{:?}", v); // COMPILE ERROR: v was moved into the function
    
    let x = 42;
    take_copy(x);
    println!("{}", x);      // Works: i32 is a Copy type, so x was copied, not moved
}

fn take_ownership(data: Vec) {
    println!("Vector length: {}", data.len());
    // data is dropped here at the end of the function scope
}

fn take_copy(num: i32) {
    println!("Number: {}", num);
}

The Drop Function and Automatic Cleanup

Rust calls the drop method automatically when a value goes out of scope. You never need to manually free memory for owned values. This is deterministic: you know exactly when cleanup happens just by reading the code structure:

fn main() {
    let outer = String::from("outer scope");
    
    {
        let inner = String::from("inner scope");
        println!("inner: {}", inner);
        // inner is dropped here—memory freed immediately
    }
    // inner no longer exists
    
    println!("outer: {}", outer);
    // outer is dropped here when main() ends
}

Borrowing and References

Immutable References (&T)

Instead of moving ownership, you can lend a value by creating a reference. Immutable references (&T) allow multiple readers simultaneously but no writers:

fn main() {
    let text = String::from("The Rust Programming Language");
    
    let len = calculate_length(&text);  // Borrow text immutably
    let first_word = first_word(&text); // Borrow again—multiple & references allowed
    
    println!("'{}' has length {} and starts with '{}'", text, len, first_word);
    // text is still valid here—ownership was never transferred
}

fn calculate_length(s: &String) -> usize {
    s.len()
    // s goes out of scope, but it was just a reference—no data is freed
}

fn first_word(s: &String) -> &str {
    let bytes = s.as_bytes();
    for (i, &byte) in bytes.iter().enumerate() {
        if byte == b' ' {
            return &s[..i];
        }
    }
    &s[..]
}

Mutable References (&mut T)

A mutable reference allows exactly one borrower to modify the data, and no other references (mutable or immutable) can coexist with it. This rule prevents data races at compile time:

fn main() {
    let mut buffer = String::from("hello");
    
    append_world(&mut buffer);  // Mutably borrow buffer
    // No other references to buffer could exist during append_world
    
    println!("Result: {}", buffer);  // Now we can read after the mutable borrow ends
}

fn append_world(s: &mut String) {
    s.push_str(", world!");
    // The mutable borrow ends here
}

The Borrow Checker in Action

The borrow checker prevents you from creating mutable references while immutable references are active, and vice versa. Here are examples that the compiler catches:

fn main() {
    let mut data = vec![10, 20, 30];
    
    // This compiles: immutable borrows happen in sequence
    let r1 = &data;
    println!("r1: {:?}", r1);
    // r1's last use is here—the borrow ends
    let r2 = &mut data;
    r2.push(40);
    println!("r2: {:?}", r2);
    
    // This does NOT compile: overlapping mutable and immutable borrows
    // Uncomment to see the error:
    /*
    let r1 = &data;
    let r2 = &mut data;  // ERROR: cannot borrow `data` as mutable while immutable borrow exists
    println!("r1: {:?}, r2: {:?}", r1, r2);
    */
}

The borrow checker analyzes the lifetime of each reference based on its last use, not just lexical scope. This is called Non-Lexical Lifetimes (NLL) and allows patterns that would have been rejected in earlier Rust versions.

Lifetimes: Making References Safe

What Are Lifetimes?

Lifetimes are the compiler's way of tracking how long references are valid. Most of the time, lifetime elision allows you to omit explicit lifetime annotations. But when a function returns a reference and there are multiple input references, you must specify which input's lifetime the output borrows:

// Lifetime elision: compiler infers the lifetimes automatically
fn first_word(s: &str) -> &str {
    // Compiler knows the output borrows from the single input
    let bytes = s.as_bytes();
    for (i, &byte) in bytes.iter().enumerate() {
        if byte == b' ' {
            return &s[..i];
        }
    }
    &s[..]
}

// Explicit lifetime annotation required: two input references
fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
    if x.len() > y.len() {
        x
    } else {
        y
    }
    // The lifetime 'a tells the compiler: the returned reference
    // lives as long as BOTH input references are valid
}

fn main() {
    let string1 = String::from("long string is long");
    let result;
    
    {
        let string2 = String::from("xyz");
        result = longest(string1.as_str(), string2.as_str());
        println!("The longest string is: {}", result);
        // string2 is dropped here
    }
    // result would be invalid here if it borrowed from string2
    // But longest() returns string1's reference, which is still valid
    
    println!("Result still valid: {}", result);  // This works
}

Lifetime Annotations in Structs

When a struct holds references, you must annotate lifetimes to tell the compiler how the struct's fields relate to each other:

// A struct that borrows string slices
struct Excerpt<'a> {
    part: &'a str,           // This reference lives for lifetime 'a
    highlight: &'a str,      // Same lifetime
    source_name: &'static str, // Static lifetime: lives for entire program
}

impl<'a> Excerpt<'a> {
    fn new(part: &'a str, highlight: &'a str) -> Self {
        Excerpt {
            part,
            highlight,
            source_name: "unknown",
        }
    }
    
    fn display(&self) -> String {
        format!("[{}] {}...", self.source_name, &self.part[..20.min(self.part.len())])
    }
}

fn main() {
    let document = String::from("The complete works of William Shakespeare...");
    let excerpt = Excerpt::new(&document[4..20], &document[20..40]);
    println!("{}", excerpt.display());
    // excerpt borrows from document—document must remain alive
}

Heap Allocation: Box, Rc, and Arc

Box: Simple Heap Allocation

Box<T> allocates a value on the heap and provides an owned pointer to it. Use Box when you need to store a value whose size isn't known at compile time (like recursive types) or when you want to transfer ownership of a large value without copying it:

// Recursive type using Box to break infinite size
enum List {
    Cons(T, Box>),
    Nil,
}

impl List {
    fn print(&self) {
        match self {
            List::Cons(value, next) => {
                print!("{} -> ", value);
                next.print();
            }
            List::Nil => println!("Nil"),
        }
    }
}

fn main() {
    // Without Box, this would be impossible—List would have infinite size
    let list = List::Cons(
        1,
        Box::new(List::Cons(
            2,
            Box::new(List::Cons(
                3,
                Box::new(List::Nil),
            )),
        )),
    );
    list.print();  // Output: 1 -> 2 -> 3 -> Nil
    
    // Box for transferring ownership efficiently
    let large_data = Box::new([0u8; 1024 * 1024]);  // 1 MB on the heap
    let recipient = large_data;  // Ownership moves—only the pointer is copied, not 1 MB
    println!("Recipient owns {} bytes", std::mem::size_of_val(&*recipient) * recipient.len());
}

Rc: Reference Counting for Shared Ownership

When you need multiple owners of the same heap-allocated data, Rc<T> provides reference counting. It's single-threaded only. Use Rc for graphs, trees, or any structure where ownership isn't strictly hierarchical:

use std::rc::Rc;

#[derive(Debug)]
struct Node {
    value: i32,
    children: Vec>,
}

impl Node {
    fn new(value: i32) -> Rc {
        Rc::new(Node {
            value,
            children: Vec::new(),
        })
    }
    
    fn add_child(self: &Rc, child: Rc) {
        // Rc::get_mut would fail if there are other references
        // For mutation with shared ownership, see RefCell later
    }
}

fn main() {
    let root = Node::new(1);
    let child_a = Node::new(2);
    let child_b = Node::new(3);
    
    // Both root and child_b can point to child_a
    // This creates a DAG (directed acyclic graph), not a strict tree
    let leaf = Node::new(4);
    
    println!("Reference count of leaf: {}", Rc::strong_count(&leaf));  // 1
    
    let another_ref = Rc::clone(&leaf);
    println!("Reference count now: {}", Rc::strong_count(&another_ref));  // 2
    
    // Both references point to the same heap allocation
    println!("Value via leaf: {}", leaf.value);
    println!("Value via another_ref: {}", another_ref.value);
    
    // When both go out of scope, the allocation is freed
}

Arc: Atomic Reference Counting for Threads

Arc<T> is the thread-safe counterpart of Rc. It uses atomic operations for reference counting, allowing shared ownership across threads:

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

fn main() {
    let shared_data = Arc::new(vec![1, 2, 3, 4, 5]);
    let mut handles = vec![];
    
    for thread_id in 0..3 {
        let data_ref = Arc::clone(&shared_data);
        let handle = thread::spawn(move || {
            // Each thread has its own Arc pointing to the same Vec
            let sum: i32 = data_ref.iter().sum();
            println!("Thread {} computed sum: {}", thread_id, sum);
        });
        handles.push(handle);
    }
    
    for handle in handles {
        handle.join().unwrap();
    }
    
    // Original Arc still valid
    println!("Original data: {:?}", shared_data);
    println!("Final reference count: {}", Arc::strong_count(&shared_data));  // 1
}

Interior Mutability: Cell and RefCell

RefCell: Runtime Borrow Checking

Normally, Rust's borrow rules are enforced at compile time. RefCell<T> moves these checks to runtime, allowing you to mutate data even when it's behind an immutable reference. This is essential when combined with Rc for shared mutable state:

use std::cell::RefCell;
use std::rc::Rc;

#[derive(Debug)]
struct MutableNode {
    value: i32,
    children: Vec>,
    // Store parent as a weak reference to avoid cycles
    parent: RefCell>>,
}

impl MutableNode {
    fn new(value: i32) -> Rc {
        Rc::new(MutableNode {
            value,
            children: Vec::new(),
            parent: RefCell::new(None),
        })
    }
    
    fn add_child(parent: &Rc, child: &Rc) {
        // Mutate the child's parent field through RefCell
        child.parent.borrow_mut().replace(Some(Rc::clone(parent)));
        // This won't compile without RefCell because parent is an immutable Rc
    }
}

fn main() {
    let root = MutableNode::new(1);
    let child = MutableNode::new(2);
    
    MutableNode::add_child(&root, &child);
    
    let parent_ref = child.parent.borrow();
    println!("Child's parent value: {}",
        parent_ref.as_ref().map(|p| p.value).unwrap_or(0));
    
    // RefCell enforces borrow rules at runtime—this would panic:
    // let mut borrow1 = child.parent.borrow_mut();
    // let borrow2 = child.parent.borrow();  // PANIC: already mutably borrowed
}

Weak References to Avoid Cycles

When using Rc, cycles prevent deallocation because reference counts never reach zero. Weak<T> provides non-owning references that don't increment the strong count:

use std::cell::RefCell;
use std::rc::{Rc, Weak};

#[derive(Debug)]
struct TreeNode {
    value: i32,
    parent: RefCell>,  // Weak reference prevents cycles
    children: RefCell>>,
}

impl TreeNode {
    fn new(value: i32) -> Rc {
        Rc::new(TreeNode {
            value,
            parent: RefCell::new(Weak::new()),
            children: RefCell::new(Vec::new()),
        })
    }
    
    fn add_child(parent: &Rc, child: &Rc) {
        parent.children.borrow_mut().push(Rc::clone(child));
        *child.parent.borrow_mut() = Rc::downgrade(parent);
    }
    
    fn get_parent(child: &Rc) -> Option> {
        child.parent.borrow().upgrade()  // Attempts to obtain Rc from Weak
    }
}

fn main() {
    let root = TreeNode::new(1);
    let child = TreeNode::new(2);
    
    TreeNode::add_child(&root, &child);
    
    // Strong count of root: 1 (only root variable)
    println!("Root strong count: {}", Rc::strong_count(&root));
    
    // Weak count: 1 (the child's parent field)
    println!("Root weak count: {}", Rc::weak_count(&root));
    
    // Upgrade weak to strong temporarily
    if let Some(parent) = TreeNode::get_parent(&child) {
        println!("Child's parent value: {}", parent.value);
        println!("Root strong count during upgrade: {}", Rc::strong_count(&root));  // 2
    }
    
    // After upgrade goes out of scope, strong count returns to 1
    println!("Root strong count after: {}", Rc::strong_count(&root));
    
    // Drop child first—root's weak count goes to 0
    drop(child);
    // Drop root—strong count goes to 0, memory is freed
    drop(root);
    // No leaks, no cycles!
}

Smart Pointers and Custom Drop Logic

The Drop Trait

You can implement Drop to run custom cleanup code when a value goes out of scope. This is how Rust ensures resources like file handles, sockets, and locks are properly released:

struct DatabaseConnection {
    connection_string: String,
    connected: bool,
}

impl DatabaseConnection {
    fn new(url: &str) -> Self {
        println!("Opening connection to {}", url);
        DatabaseConnection {
            connection_string: url.to_string(),
            connected: true,
        }
    }
    
    fn query(&self, sql: &str) {
        if self.connected {
            println!("Executing: {}", sql);
        } else {
            println!("Cannot query: connection closed");
        }
    }
}

impl Drop for DatabaseConnection {
    fn drop(&mut self) {
        if self.connected {
            println!("Closing connection to {}", self.connection_string);
            self.connected = false;
            // In real code: flush buffers, send disconnect packet, free resources
        }
    }
}

fn main() {
    {
        let db = DatabaseConnection::new("postgres://localhost/mydb");
        db.query("SELECT * FROM users");
        // db goes out of scope here—Drop::drop is called automatically
    }
    println!("Connection has been cleaned up");
    
    // You can also call drop early with std::mem::drop
    let early_db = DatabaseConnection::new("mysql://localhost/test");
    early_db.query("SHOW TABLES");
    std::mem::drop(early_db);  // Explicitly trigger Drop
    println!("Early drop completed");
}

Box for Trait Objects and Dynamic Dispatch

Box enables dynamic dispatch by storing trait objects. The compiler doesn't know the concrete type at compile time, but Box stores a vtable pointer for runtime dispatch:

trait Animal {
    fn speak(&self) -> String;
    fn name(&self) -> &str;
}

struct Dog {
    name: String,
}

impl Animal for Dog {
    fn speak(&self) -> String {
        "woof!".to_string()
    }
    fn name(&self) -> &str {
        &self.name
    }
}

struct Cat {
    name: String,
    lives_remaining: u8,
}

impl Animal for Cat {
    fn speak(&self) -> String {
        "meow!".to_string()
    }
    fn name(&self) -> &str {
        &self.name
    }
}

fn main() {
    // Heterogeneous collection of animals via Box
    let mut zoo: Vec> = Vec::new();
    
    zoo.push(Box::new(Dog {
        name: "Rex".to_string(),
    }));
    zoo.push(Box::new(Cat {
        name: "Whiskers".to_string(),
        lives_remaining: 9,
    }));
    
    // Dynamic dispatch: the correct method is called at runtime
    for animal in &zoo {
        println!("{} says '{}'", animal.name(), animal.speak());
    }
    
    // Downcasting: recover the concrete type
    if let Some(cat) = zoo[1].downcast_ref::() {
        println!("Cat has {} lives remaining", cat.lives_remaining);
    }
}

Unsafe Rust: When You Need to Break the Rules

Understanding Unsafe Blocks

Rust's safety guarantees are enforced by the compiler, but sometimes you need to perform operations the compiler can't verify. unsafe blocks allow you to dereference raw pointers, call unsafe functions, access unions, and implement unsafe traits. The key principle: unsafe doesn't mean the borrow checker is off—it means you're taking responsibility for invariants the compiler can't check.

fn main() {
    // Splitting a mutable slice into two disjoint mutable halves
    let mut numbers = vec![1, 2, 3, 4, 5, 6];
    
    let (left, right) = numbers.split_at_mut(3);
    left[0] = 100;
    right[0] = 400;
    
    println!("Left half: {:?}", left);   // [100, 2, 3]
    println!("Right half: {:?}", right); // [400, 5, 6]
    
    // Implementing split_at_mut ourselves with unsafe
    let mut raw_vec = vec![10, 20, 30, 40, 50, 60];
    let ptr = raw_vec.as_mut_ptr();
    
    unsafe {
        // We know these two ranges don't overlap—we're guaranteeing safety
        let left = std::slice::from_raw_parts_mut(ptr, 3);
        let right = std::slice::from_raw_parts_mut(ptr.add(3), 3);
        
        left[0] = 999;
        right[0] = 888;
        
        println!("Custom split: left={:?}, right={:?}", left, right);
    }
}

FFI and Manual Memory Management

When interfacing with C libraries, you must manually manage memory. Here's an example wrapping a hypothetical C library:

// Simulating a C library that allocates and frees memory
mod c_library {
    #[repr(C)]
    pub struct CBuffer {
        pub data: *mut u8,
        pub length: usize,
        pub capacity: usize,
    }
    
    // Simulated C function: allocates a buffer on the heap
    pub unsafe fn create_buffer(size: usize) -> *mut CBuffer {
        let layout = std::alloc::Layout::new::();
        let ptr = std::alloc::alloc(layout) as *mut CBuffer;
        if ptr.is_null() {
            return std::ptr::null_mut();
        }
        
        let data_layout = std::alloc::Layout::array::(size).unwrap();
        let data_ptr = std::alloc::alloc(data_layout);
        
        unsafe {
            (*ptr).data = data_ptr;
            (*ptr).length = 0;
            (*ptr).capacity = size;
        }
        ptr
    }
    
    // Simulated C function: frees the buffer
    pub unsafe fn destroy_buffer(ptr: *mut CBuffer) {
        if ptr.is_null() {
            return;
        }
        unsafe {
            let data_ptr = (*ptr).data;
            let capacity = (*ptr).capacity;
            
            let data_layout = std::alloc::Layout::array::(capacity).unwrap();
            std::alloc::dealloc(data_ptr, data_layout);
            
            let buffer_layout = std::alloc::Layout::new::();
            std::alloc::dealloc(ptr as *mut u8, buffer_layout);
        }
    }
}

// Safe Rust wrapper around the unsafe C library
struct SafeBuffer {
    inner: *mut c_library::CBuffer,
}

impl SafeBuffer {
    fn new(capacity: usize) -> Option {
        let ptr = unsafe { c_library::create_buffer(capacity) };
        if ptr.is_null() {
            None
        } else {
            Some(SafeBuffer { inner: ptr })
        }
    }
    
    fn write(&mut self, data: &[u8]) -> Result {
        unsafe {
            let buffer = &mut *self.inner;
            if data.len() > buffer.capacity - buffer.length {
                return Err("buffer full");
            }
            let dest = buffer.data.add(buffer.length);
            std::ptr::copy_nonoverlapping(data.as_ptr(), dest, data.len());
            buffer.length += data.len();
            Ok(data.len())
        }
    }
}

impl Drop for SafeBuffer {
    fn drop(&mut self) {
        unsafe { c_library::destroy_buffer(self.inner); }
    }
}

fn main() {
    let mut buf = SafeBuffer::new(1024).expect("allocation failed");
    buf.write(b"Hello, FFI!").unwrap();
    println!("Buffer written successfully");
    // buf is dropped here—C memory is freed via Drop
}

Best Practices for Rust Memory Management

1. Prefer Stack Allocation When Possible

The stack is faster and automatically managed. Use heap types (Box, Vec, String) only when you need dynamic sizing, shared ownership, or long-lived data:

fn process_data() {
    // Stack-allocated: fast, no indirection
    let fixed_array = [1, 2, 3, 4, 5];
    let sum: i32 = fixed_array.iter().sum();
    println!("Sum: {}", sum);
    
    // Heap-allocated: needed because size is dynamic
    let dynamic_vec: Vec = (0..1000).collect();
    let dynamic_sum: i32 = dynamic_vec.iter().sum();
    println!("Dynamic sum: {}", dynamic_sum);
}

2. Use References Over Ownership for Read-Only Access

Pass &T instead of T to functions that don't need ownership. This avoids unnecessary moves and keeps the original value usable:

// Good: borrows data, doesn't consume it
fn analyze_config(config: &HashMap) -> bool {
    config.contains_key("debug_mode")
}

// Avoid: takes ownership unnecessarily (unless you truly need to consume it)
fn bad_analyze_config(config: HashMap) -> bool {
    config.contains_key("debug_mode")
    // config is dropped here—caller loses access
}

3. Minimize Lifetime Annotations with Elision

Rust's lifetime elision rules handle most common cases. Only add explicit lifetimes when the compiler requires them—typically when returning a reference from a function with multiple reference parameters:

// Elision works: one input reference, one output reference
fn trim_prefix(s: &str) -> &str {
    &s[1..]
}

// Elision works: method taking &self or &mut self
impl Config {
    fn get_host(&self) -> &str {
        &self.host
    }
}

// Explicit lifetime needed: multiple input references
fn select<'a>(first: &'a str, second: &str, choose_first: bool) -> &'a str {
    if choose_first { first } else { second }
    // This won't compile without 'a because second's lifetime differs
}

4. Use Rc/Arc with RefCell/Mutex for Shared Mutable State

When you need multiple owners and mutation, combine Rc with RefCell (single-threaded) or Arc with Mutex/RwLock (multi-threaded):

use std::sync::{Arc, Mutex};
use std::thread;

fn main() {
    // Thread-safe shared mutable counter
    let counter = Arc::new(Mutex::new(0));
    let mut handles = vec![];
    
    for _ in 0..10 {
        let counter = Arc::clone(&counter);
        let handle = thread::spawn(move || {
            let mut num = counter.lock().unwrap();
            *num += 1;
            // Mutex is unlocked here when num goes out of scope
        });
        handles.push(handle);
    }
    
    for handle in handles {
        handle.join().unwrap();
    }
    
    println!("Final count: {}", *counter.lock().unwrap());  // 10
}

5. Avoid Unnecessary Cloning

Cloning heap-allocated data is expensive. Borrow first, clone only when ownership is required:

fn process_report(data: &Vec) -> f64 {
    // Borrowing: zero-cost
    data.iter().sum::() / data.len() as f64
}

fn archive_report(data: Vec) {
    // Ownership required: caller must clone if they want to keep the original
    println!("Archiving {} data points", data.len());
}

fn main() {
    let measurements = vec![1.1, 2.2, 3.3, 4.4, 5.5];
    
    let avg = process_report(&measurements);  // Borrow—no clone
    println!("Average: {}", avg);
    
    // Clone only when we need to keep the original AND transfer ownership
    archive_report(measurements.clone());  // Clone here
    println!("Original still usable: {:?}", measurements);
}

6. Profile Before Optimizing Allocations

Don't prematurely micro-optimize memory. Use profiling tools like valgrind, heaptrack, or Rust's dhat allocator profiler to identify actual bottlenecks. The default global allocator is efficient for most use cases. Consider custom allocators like jemalloc or mimalloc only when profiling shows allocation overhead is significant:

// Using a custom allocator (requires adding jemallocator crate to Cargo.toml)
// use jemallocator::Jemalloc;
// #[global_allocator]
// static GLOBAL: Jemalloc = Jemalloc;

fn main() {
    // Default allocator handles this fine
    let large_vec: Vec = (0..1_000_000).collect();
    println!("Vector of {} elements allocated", large_vec.len());
    // Profile first before switching allocators
}

7. Keep Unsafe Code Isolated and Documented

When you must use unsafe, encapsulate it in a safe abstraction, document the invariants that callers must uphold, and keep unsafe blocks as small as possible:

/// A safe wrapper around an unsafe raw pointer buffer.
/// 
/// # Safety Invariants
/// - The pointer must be non-null and properly aligned
/// - The length must not exceed the allocated capacity
/// - Only this struct may access the pointer while it exists
struct SafeRawBuffer {
    ptr: *mut u8,
    len: usize,
    cap: usize,
}

// SAFETY: SafeRawBuffer owns its data and doesn't share it across threads
// without synchronization, so Send is safe.
unsafe impl Send for SafeRawBuffer {}

impl SafeRawBuffer {
    /// Creates a new buffer. The caller must ensure the pointer is valid
    /// for reads and writes of `cap` bytes.
    /// 
    /// # Safety
    /// - ptr must be a valid, non-null pointer to allocated memory
    /// - cap must match the actual allocation size
    unsafe fn from_raw_parts(ptr: *mut u8, cap: usize) -> Self {
        SafeRawBuffer { ptr, len: 0, cap }
    }
}

Common Pitfalls and How to Avoid Them

Pitfall 1: Accidentally Moving Values Out of Structs

struct Task {
    name: String,
    completed: bool,
}

impl Task {
    fn name(&self) -> String {
        // Bad: moves name out of self (destroys the struct field)
        // self.name  // Would cause partial move error

🚀 Need a reliable AI agent for your project?

Deploy Hermes Agent in 10 minutes. Managed hosting, zero DevOps.

Get Started — $23.99/mo
← Back to all articles