Engineering Insight Architecture Best Practices ~10 min read

Thinking in Rust: Ownership, Access, and Memory Safety

A mental framework for Rust's memory safety concepts. Think systematically about ownership, references, Send, Sync, and Rc, Arc, RefCell, Mutex, etc.


Updated Jul 9, 2026

I’m an experienced C++ programmer, but still feel that I need a mindset shift when starting to work with Rust.

  • References can be immutable (&) or mutable (&mut), which is straightforward for the simplest cases. There are more complex cases, e.g. there are data types with interior mutability (e.g. RefCell), there are multi-level references (& &mut).
  • Rust also models memory safety in multi-thread scenarios, so there’s Send and Sync. They have intricate rules related to various types of references, e.g. when is &T Send or Sync? How about &mut T?
  • There are various data types, like Rc , Arc, Cell, RefCell, Mutex, RwLock, Cow . When to pick which?

Each single rule is well documented from the bottom up, but when putting things together to design a complex system, it’s easy to get overwhelmed by all these intricacies. So it would be very helpful if we had a clear mental model to help us think in a more rationalized way.

This article is not a tutorial to introduce these concepts, but provides a mental framework, as a simple and natural interpretation of Rust’s memory safety models. I hope with this, Rust’s rules around memory safety and capability of related built-in data types will be more easily understandable, based on rationalized coherent ideas instead of memorizing many rules.

Basic concepts: ownership and access

A variable represents some resource in the memory. When talking about the relationship between the variable and the relevant resource, there are two types of roles: ownership and access.

rust
let s: String = "Hello".to_string();  // Variable `s` owns the data
println!("{}", s);      // Variable `s` has access to the data

let p: &String = &s;    // Create a reference `p`
println!("{}", p);      // `p` also has access to the same data

Access: shared vs exclusive

Rust classifies variables and their references as immutable (default) and mutable (mut). When it comes to some more intricate data types, the boundary is less clear. e.g. if my data type uses a cache to accelerate access, is it mutating things? What about some statistics collections? What about talking to a remote backend?

I find things become clearer suddenly after switching to a different viewpoint: thinking in terms of shared access and exclusive access.

  • Shared Access (default): You can access the resource, without excluding others from holding shared access simultaneously.
  • Exclusive Access (mut): You have sole permission to use and modify the resource. No one else can access it during this period.

The level of access you have determines what you can do with the resource. Shared access is sufficient for read operations. Write operations require exclusive access. For methods of a resource, its receiver type (&self or &mut self) indicates what level of access is needed by this method. When you wrap a resource with something offering interior mutability (e.g. Mutex ), the wrapped resource can be accessed in a shared way, while its implementation provides run-time protection to only grant access exclusively at runtime.

Exclusive access implies shared access, i.e. what you can achieve with shared access, can always be achieved with exclusive access.

Ownership implies lifetime keeper + exclusive access

When a variable owns a resource, there are two implications:

  • Lifetime Keeper: You hold the lifetime of the resource. Before you’re out of scope (e.g. end of the block that defines the variable), the ownership is either moved, or the resource needs to be dropped.
  • Access: You have access to it through the entire lifetime of the ownership (unless during the period when it’s lent exclusively).

Note that the access here can also be shared or exclusive, depending on whether you used mut when introducing it. However, even without mut, since you have the ownership, you can easily upgrade it to a mutable variable, e.g.

rust
fn foo(s: String) {
  // You cannot do this, as you don't have exclusive access yet
  //! s.push_str(" world");

  // However, since you have ownership, it's easy to claim exclusive access
  let mut s = s;
  // Now we have exclusive access, so can modify it.
  s.push_str(" world");
  ...
}

So once you own a resource, whether or not you have mut isn’t part of the contract. You have the same permission as exclusive access, i.e. ownership implies exclusive access.

Lifetime keeping isn’t another permission. For a resource, you can do almost everything with exclusive access: with std::mem::take() and std::mem::replace(), you can even drop the resource with exclusive access. Hence exclusive access is equivalent to ownership in terms of permission. Keep this in mind - it will be helpful to understand rules around Send and Sync later.

What each form grants: a shared reference has shared access (read); an exclusive reference has shared and exclusive access (read and write) but no lifetime role; the owner has identical permission to an exclusive reference, plus the lifetime-keeper role (move, drop, keep alive). In short: exclusive access + lifetime keeper = ownership.

Moves vs. borrows: transferring ownership or access

Move and borrow are two very unique concepts in Rust. Both are about passing around resources: one for ownership, one for access.

Rust provides two ways to pass around resources:

  • Move: Permanently transfers ownership to another variable. It’s a one-way ticket, and after moving, the original owner loses ownership and access.

    rust
    let s = "Hello".to_string();
    let t = s; // ownership moves to t; s is no longer valid
  • Borrow (Exclusive or Shared Access): Temporarily grants access to a reference without giving up ownership. It will suspend your exclusive access (as you no longer exclusively hold it) until the end of the borrow.

    rust
    let mut s = String::from("hello");
    let r = &mut s; // exclusive access borrowed
    r.push_str(" world");
    // Borrow ends here; s is usable again

    You can grant the access as long as you have the same level of access, even if you don’t have the ownership. Once an exclusive access is lent out, your access is suspended until the borrow ends, by the definition of exclusivity. Once shared access is lent out, you only retain shared access, as you no longer exclusively hold the access.

Note

Technically, when you grant access from &T or &mut T, slightly different things happen:

  • It’s called reborrow when you pass on access from &mut T.
  • A copy happens when you grant access from &T, as shared access can be freely shared without suspending the current access.

Move versus borrow. A move hands ownership from s to t one way; s is gone and t is the one owner. An exclusive borrow lends &mut s to a single reference; s stays the owner, pauses while lent, and resumes when the borrow ends. A shared borrow lends &s to many readers at once, and the owner can still read too.

Send and Sync: access across threads

Rust ensures thread-safety explicitly using two traits: Send and Sync. There are 3 rules around trait transitivity on &T and &mut T; however, anchoring them to the concepts of ownership and access will make things simpler to understand.

To define Send and Sync:

  • Send: Indicates that ownership can safely be transferred to another thread. Hence when T is Send, T can be moved across the thread boundary.

    Corollary: As we discussed earlier, exclusive access is equivalent to ownership in terms of permission. So whenever we allow transferring ownership across threads, nothing can go wrong for shared access across the thread boundary, and vice versa. So Send also indicates the safety of transferring exclusive access across threads.

  • Sync: Indicates shared access can be safely transferred across the thread boundary.

Send and Sync across a thread boundary: Send means ownership (equivalently exclusive access) may move to another thread; Sync means shared access (a reference) may be shared with another thread. A reference &T is Send if and only if T is Sync.

From here, we can reason about the 3 rules regarding trait transitivity:

  1. By the definition of Sync above, &T is Send if and only if T is Sync.
  2. By the definition and corollary of Send above, &mut T is Send if and only if T is Send.
  3. For both &T and &mut T, their shared access (& &T and & &mut T) grants and only grants shared access of T . So &T and &mut T are Sync if and only if T is Sync.

You don’t need to memorize any rules. Think clearly about which levels of access are allowed to happen across threads, and nothing can go wrong.

Smart wrappers to defer compile-time safety to runtime

Rust tries hard to enforce safety at compile time, but it can be rigid sometimes. Some scenarios need more flexibility. Rust provides smart wrappers to defer compile-time safety guards to runtime:

  • Smart wrappers for interior mutability (Cell, RefCell, Mutex, RwMutex). With these, only shared access on the wrapper is needed for exclusive access on the interior resource.

    • Cell is the simplest one, exposing no API to return references with exclusive access. The main API to set the value is:

      rust
      pub fn set(&self, val: T)

      It doesn’t expose any exclusive access outside the function. Also, it declares to be !Sync, so all methods are guaranteed not to be called simultaneously. Hence it guarantees exclusive access isn’t held concurrently without any extra runtime check.

    • RefCell also declares to be !Sync, while it exposes methods that return proxy to access:

      rust
      // Expose shared access to T
      pub fn borrow(&self) -> Ref<T>
      // Expose exclusive access to T
      pub fn borrow_mut(&self) -> RefMut<T>

      While Ref and RefMut hold access to the interior resource T after the methods return, it needs to perform a runtime check to make sure whenever RefMut is returned no other Ref or RefMut is live.

    • Mutex and RwMutex offer a similar interface to RefCell, but are both Sync - they’re safely shared across threads. To achieve safety, it uses lock and read-write lock internally.

  • Smart wrappers for shared ownership (Rc , Arc ). With these, ownership on the interior resource can be shared among multiple parties, and it’s dropped when the last owner no longer holds the smart wrapper. Shared ownership on the interior resource can only safely grant shared access. When it comes to exclusive access, they provide the following methods:

    rust
    pub fn get_mut(this: &mut Rc<T>) -> Option<&mut T>
    pub fn get_mut(this: &mut Arc<T>) -> Option<&mut T>

    which do a runtime check to make sure this is the sole owner, and return None otherwise. Internally, they do reference counting, while Rc is cheaper but thread unsafe (!Send + !Sync), and Arc is thread safe (Send + Sync) when T is thread safe (Send + Sync).

  • Smart wrappers for clone-on-write (Cow) leave ownership versus shared access undecided at compile time. Shared access is always guaranteed without overhead. Whenever exclusive access is needed, it clones the interior resource.

When thinking from the angle of ownership and access, the purpose, contract, and behavior of these smart wrappers become more easily explainable. The choice comes down to two questions: are you sharing access or sharing ownership, and does it need to cross threads?

Choosing a smart wrapper by two questions: sharing access or sharing ownership, and does it cross threads. Sharing access: Cell and RefCell single-thread, Mutex and RwLock thread-safe. Sharing ownership: Rc single-thread, Arc thread-safe. Cow stays undecided until a write and clones only when exclusive access is needed.

Why this matters for a data engine

This mental model isn’t academic for us — it’s the daily reality of building CocoIndex’s engine. The core is async-first on Tokio, which means values routinely cross thread boundaries: a future started on one worker thread can be polled to completion on another. The moment you put a value into a task that the runtime may move between threads, the compiler demands that value be Send. Reading that requirement through the ownership lens above — “Send means ownership (and therefore exclusive access) can safely move across a thread boundary” — turns a cryptic compiler error into an obvious design question: should this resource really be handed off to another thread, and is everything it transitively owns safe to hand off too?

The smart-wrapper choices fall out of the same reasoning. State that’s genuinely shared across concurrent tasks — connection pools, cached schema, context values — lives behind Arc, because we need shared ownership with a lifetime that ends only when the last task lets go, and Arc is the thread-safe counter (Rc would be !Send and wouldn’t compile into a multi-threaded task). Where that shared state also needs mutation, we reach for the Sync interior-mutability wrappers (Mutex/RwLock) so that shared access to the wrapper can yield exclusive access to the interior, with the runtime check enforcing exclusivity. The single-writer discipline around our internal LMDB storage is exactly this principle applied at the I/O layer: many tasks hold shared access to the database handle, but writes are funneled so that mutation is serialized rather than racing.

The mental model at work in CocoIndex's async engine. Futures hop between Tokio worker threads, so their values must be Send. State shared across tasks lives behind Arc with Mutex or RwLock: shared ownership plus runtime-checked exclusive writes. LMDB storage takes many shared readers in parallel while writes are funneled to one exclusive writer at a time.

The same separation of what a value is from how it may be accessed underpins our type-guided serialization: a value the engine owns can be moved into the serializer and reconstructed on the other side, while a value merely borrowed can only be read. Thinking in ownership and access keeps these decisions principled instead of trial-and-error against the borrow checker.

Conclusion

By clearly separating and defining ownership and exclusive versus shared access, Rust’s complexity transforms into logical clarity. Moves, borrows, Send, Sync, and runtime checks become intuitive and predictable tools in your programming toolbox.

With this mental model, you can confidently explore even advanced Rust, while keeping your understanding grounded and practical.

Support us

CocoIndex is an open source project built on Rust. Rust is the number one choice for any modern data engine, and these ownership and access ideas underpin how CocoIndex handles type-guided serialization and its internal storage. If this article is helpful to you, please drop a star ⭐ at GitHub to support this project.

CocoIndex

Fresh context for long-horizon agents.

Frequently asked questions.

What is the difference between ownership and access in Rust?

They are two distinct roles a variable can have over a resource. Ownership means you hold the resource's lifetime (it is moved or dropped when you go out of scope) and you have access to it. Access is the permission to use the resource, which can be held by the owner or by a reference. See Basic concepts: ownership and access.

What is the difference between shared and exclusive access in Rust?

Thinking in terms of shared versus exclusive access is clearer than mutable versus immutable. Shared access (the default) lets you use the resource without excluding others from also holding shared access; it is enough for reads. Exclusive access (mut) gives sole permission to use and modify the resource, and is required for writes. Exclusive access implies shared access. See Access: shared vs exclusive.

Why is exclusive access equivalent to ownership in terms of permission?

With exclusive access you can do almost everything: using std::mem::take() and std::mem::replace() you can even drop the resource. Lifetime-keeping is not a separate permission. So in terms of what you are allowed to do, exclusive access is equivalent to ownership — a key idea for reasoning about Send and Sync later. See Ownership implies lifetime keeper + exclusive access.

What is the difference between a move and a borrow in Rust?

A move permanently transfers ownership to another variable — a one-way ticket, after which the original owner loses ownership and access. A borrow temporarily grants access (shared or exclusive) to a reference without giving up ownership; an exclusive borrow suspends your own access until it ends, while a shared borrow leaves you with shared access. See Moves vs. borrows.

How do I understand the Send and Sync rules in Rust?

Anchor them to access. Send means ownership (and therefore exclusive access) can be transferred to another thread; Sync means shared access can be transferred across threads. From these, the three transitivity rules follow: &T is Send iff T is Sync; &mut T is Send iff T is Send; and both &T and &mut T are Sync iff T is Sync. See Send and Sync: access across threads.

When should I use Cell, RefCell, Mutex, Rc, Arc, or Cow?

They are smart wrappers that defer compile-time safety to runtime. For interior mutability, Cell and RefCell are !Sync (single-thread), while Mutex and RwLock are Sync and use locking for cross-thread safety. For shared ownership, Rc is cheaper but thread-unsafe and Arc is thread-safe; both expose exclusive access only via a runtime check that you are the sole owner. Cow leaves ownership vs. shared access undecided, cloning only when exclusive access is needed. See Smart wrappers to defer compile-time safety to runtime.

What is interior mutability and how do these wrappers provide it?

Interior mutability lets you obtain exclusive access to a resource while holding only shared access to its wrapper. Cell exposes no references and sets values via set(&self, val); RefCell returns Ref/RefMut proxies guarded by a runtime check so no RefMut coexists with another borrow; Mutex/RwLock offer a similar interface but are safe across threads via locks. See Smart wrappers to defer compile-time safety to runtime.