I Thought It Was Taste: `&self`, `&mut self`, `self`, and What a Move Really Does

Every time I write a Rust method I get stuck on the same choice: &self, &mut self, or self. For a while I assumed it was a matter of taste — &mut self if you need to mutate, &self otherwise, and self when you want to be a bit more forceful. That turned out to be wrong.

This post covers what actually separates the three receivers, what a move really does, and why you'd use self at all when you can't use the original afterwards. It does not cover where exactly the mut in a builder's mut self attaches — I haven't dug into that yet. Every snippet and error message below came from a local rustc 1.97.0.

Before we start — two terms

  • move / ownership: in Rust every value has exactly one owner, and the value is cleaned up when that owner goes away. A move is a change of owner — the value goes to a new owner, and the name that used to own it can no longer use it.
  • place / value: writing c.name refers to two things at once: the name slot inside c — the place — and the data stored in that slot — the value. The Rust Reference also separates these as place expressions and value expressions. This is the most important distinction in the post.

1. The three receivers are not a matter of taste

I understood that mutating meant &mut self. What I thought was that &self versus self was just taste. It isn't.

They behave differently, and one question decides it: can you still use the instance after the call?

Rust
struct Counter { count: u32 }

impl Counter {
    fn get(&self) -> u32 { self.count }          // read only
    fn increment(&mut self) { self.count += 1; } // mutate
    fn into_count(self) -> u32 { self.count }    // consume — takes ownership of the instance
}
receiverwhat it can dooriginal after the callrequirement on the caller
&selfread onlystill usable (call it as often as you like)none
&mut selfmutate fieldsstill usablethe variable must be let mut
selftake a value out / convert / consumeunusable (moved)the original can't be used once you call it
Rust
let c = Config { name: "hello".into() };

let a = c.peek();  // &self — only borrows, c can still be used
let b = c.peek();  // so calling it again is fine

let s = c.take();  // self — ownership leaves. c is moved here
// c.peek();       // compile error: c has already been moved

&mut self has a condition too: the calling variable has to be declared let mut.

TEXT
error[E0596]: cannot borrow `c` as mutable, as it is not declared as mutable
7 |     c.increment();
  |     ^ cannot borrow as mutable
help: consider changing this to be mutable
6 |     let mut c = Counter { count: 0 };

Using &mut self on a method that changes nothing makes everyone who uses the type add an unnecessary mut. It's better to default to &self and change it only when needed.

2. A move doesn't destroy the value

When I saw that calling a self method meant I couldn't use the original anymore, my first thought was that the value had been taken apart. Was it just gone? Was this like JavaScript's undefined? Neither.

What becomes unusable is not the value but the original variable (binding).

Rust
let s = c.take_name();
// the value "hello" → still exists. It just belongs to s now.
// the variable c    → the compiler stops this name from being used.

The value only changed owners; nothing was destroyed. Rust blocks the original name to prevent a double free — if two names each treated the same value as their own, it would be cleaned up twice. There can be only one owner, so the other names are blocked.

undefined differs from this at a more basic level.

JS undefineda moved variable in Rust
is there a valueyes, an actual valuethe value went to its new owner; this variable has no value
when is it decidedruntimecompile time
what if you read ityou get undefined and the program keeps runningthe compile is rejected; no binary is produced at all
when the problem shows upmuch later, at runtimeat compile time, before a binary exists

A moved variable doesn't hold a special "empty" value. The compiler just determines that this name can't be used from this point on. That's why it costs nothing at runtime, and the binary contains no trace of the move.

3. So why use self at all?

Even after that, I kept getting stuck. Why use it if the original can't be used afterwards? Was it a performance thing, an optimization? Both questions started from a wrong premise.

One thing has to be clear first. In Rust a move is not an optimization technique; it's what assignment does by default. With no method and no self, a plain assignment still moves.

Rust
let a = String::from("plain let binding, no method call at all");
let b = a;              // not a method. Just an assignment
let n = eat(b);         // passing it to a function is a move too
TEXT
a  ptr = 0x102b2dcb0
b  ptr = 0x102b2dcb0    <- same buffer. And from here on, a is an E0382

So fn take(self) is the rule already present in let b = a, applied to a method's first argument. Coming from C++ or JavaScript it reads as "copying is the default and zero-copy is the optimization," but Rust is the other way round: moving is the default, and copying (clone()) has to be requested explicitly.

So the question changes. Instead of "why use it when you lose the original," the better question is "what do you get in return for not being able to use the original name?" There are three things.

You take over the data without copying it

String::into_bytes takes self.

TEXT
String  ptr = 0x1056c5c50, len = 63
Vec<u8> ptr = 0x1056c5c50, len = 63   <- same address. The heap buffer was handed over as is

&self can't do this even in principle. You can't take the buffer of a borrowed value, so copying is the only option.

TEXT
s2      ptr = 0x1056c5cd0
to_vec  ptr = 0x1056c5d10   <- freshly allocated, 63 bytes copied

Converting a String into a Vec<u8> costs either zero heap allocations or one. That's why the whole into_* family in the standard library takes self. The buffer can be reused because the original name won't be used again; if that name were still usable, two names would own one buffer, and the compiler couldn't hand it over.

The compiler blocks things that must not happen twice

Rust
impl Conn { fn close(self) { println!("closed"); } }

c.close();
c.close();   // the second one
TEXT
error[E0382]: use of moved value: `c`
7 |     c.close();
  |       ------- `c` moved due to this method call
8 |     c.close();
  |     ^ value used here after move
note: `Conn::close` takes ownership of the receiver `self`, which moves `c`

Code that closes an already-closed connection doesn't compile. Instead of checking a runtime flag with if self.closed { panic!() }, the type system rules it out. In this case, not being able to use the original afterwards is exactly the behavior you want.

You don't need the original anyway

Once every field has been taken out, the remaining struct is useless, and a builder has no further use after .build(). If you make those &self, the caller has to clone() an original nobody is going to use.

Looking only at performance is misleading

A move isn't free. A String itself is 24 bytes — pointer, length, capacity — and those 24 bytes may be copied on the stack when it moves. What isn't moved is the actual data on the heap. Moving large structs repeatedly appears to leave some of that stack copying in place (it's generally said to be optimized away, though I didn't verify that here). Put precisely, the benefit of a move isn't "it's fast" but "the heap data isn't reallocated."

And if you use self for performance when the original is still needed, it backfires. The caller wants to keep using the original, so they call clone(), and you end up with more copying, not less.

So use self when "this instance is done here" is actually true, and the performance benefit comes along in that case. If the original is still needed afterwards, use &self. Pick the one that fits.

4. What moves is the value, not the place

This is where it clicked. I asked, "Ah, so I'm not getting c.name itself, I'm getting the value that was in c.name?" — and that was right.

c.name combines two things.

  • place: the name slot inside the c struct. A memory location.
  • value: the data in that slot. "hello".
TEXT
c's name slot:  [ "hello" ]
                    │  move — only the value moves. The slot belongs to c and can't be moved
                    ▼
s:              [ "hello" ]   ← s owns this value now

A move in Rust always moves a value. With that in mind, the earlier sections can be summarized like this.

  • &self = showing the place briefly. The value stays in c, so c can still be used.
  • self = moving the value to a different place. The original slot is left empty, and the name c can no longer be used.

into_bytes taking over the heap address in section 3 is the same thing: what moved was the pointer value stored in that place.

5. mut attaches to the binding

Then I wondered whether let mut s = c.take() would let me mutate the result, even though c itself was never mut. It does, and it doesn't matter what c was.

Rust
let c = Config { name: "hi".into() };  // c is immutable
let mut s = c.take();                  // the receiving side declares mut afresh
s.push_str("!");                       // fine. prints: hi!

A move only transfers ownership. Whether it can be mutated is decided by the variable that receives it: with let mut s you can mutate it, with let s you can't. The data doesn't carry any "mutable" information with it. mut is the permission that a name currently has.

And mutating s can't be described as "mutating c.name." The value belongs to s now, and there's no c.name left to mutate. The place/value distinction from section 4 applies here too: the place stayed with c but is empty, and the value is in s.

6. self takes the whole struct, even if you only take out one field

With both name and port on c, I figured calling take_name() would only take name and leave port. It doesn't.

Rust
struct Config { name: String, port: u32 }
impl Config {
    fn take_name(self) -> String { self.name }
}

let c = Config { name: "hi".into(), port: 8080 };
let s = c.take_name();
println!("{}", c.port);   // error
TEXT
error[E0382]: borrow of moved value: `c`
7 |     let s = c.take_name();
  |               ----------- `c` moved due to this method call
8 |     println!("{} {}", s, c.port);
  |                          ^^^^^^ value borrowed here after move
note: `Config::take_name` takes ownership of the receiver `self`, which moves `c`

The method doesn't receive name; it receives self, which is all of c. What gets moved is determined by the receiver, not by the return type. Returning only name still moves ownership of the whole struct; the name c as a whole can no longer be used, so port is blocked too. The port that wasn't taken out is dropped when the method ends.

The exception — without a method, only the field moves

Rust
let c = Config { name: "hi".into(), port: 8080 };
let n = c.name;                // move the field directly
println!("{} {}", n, c.port);  // fine. prints: hi 8080

This is called a partial move. Only the moved field is blocked.

TEXT
error[E0382]: borrow of moved value: `c.name`
4 |     let n = c.name;
  |             ------ value moved here
5 |     println!("{} {}", n, c.name);
  |                          ^^^^^^ value borrowed here after move

The error points to c.name, not c. That means the compiler tracks moves per field.

  • c.take_name() → the method takes self → all of c moves → port is blocked too
  • let n = c.name → only the field moves → partial move → the other fields remain usable

The result depends on whether you go through a method or access the field directly. If you want to take out several fields, return them together in a tuple.

Rust
fn into_parts(self) -> (String, String, u32) {
    (self.name, self.host, self.port)  // each one off to a new owner
}

The exception to the exception — implementing Drop blocks partial moves

Rust
impl Drop for Config { fn drop(&mut self) {} }

let n = c.name;
TEXT
error[E0509]: cannot move out of type `Config`, which implements the `Drop` trait
5 |     let n = c.name;
  |             ^^^^^^ cannot move out of here

drop(&mut self) receives the whole struct, intact, when the value goes away. A struct with a field already moved out can't be passed to it, so the compiler blocks moving the field out in the first place. So a partial move is only allowed when no code will use the struct as a whole again.

7. Copy types don't move in the first place

Copy marks a type as one where copying the bits produces a complete duplicate. On assignment or argument passing, instead of invalidating the original, Rust makes another copy. The value is duplicated.

Within one struct, it depends on each field's type.

Rust
struct Config { name: String, port: u32 }

let c = Config { name: "hi".into(), port: 8080 };
let p = c.port;                            // u32 is Copy
println!("{} {} {}", p, c.port, c.name);   // fine. prints: 8080 8080 hi

c.port was taken out, yet both c.port and c.name can still be used — the opposite of section 6, where c.name couldn't be used after let n = c.name. The syntax is the same; whether the value moves or is copied depends on the type.

If the whole struct is Copy, even a self receiver doesn't invalidate the original.

Rust
#[derive(Clone, Copy)]
struct Point { x: i32, y: i32 }
impl Point { fn into_x(self) -> i32 { self.x } }

let p = Point { x: 1, y: 2 };
let a = p.into_x();
let b = p.into_x();                // a second call goes through
println!("{} {} {}", a, b, p.y);   // prints: 1 1 2

So the rule "taking self makes the original unusable" holds precisely only for non-Copy types. The fn close(self) design from section 3, which blocks a second call, doesn't work for a Copy type.

The error message in section 6 was already stating that condition.

TEXT
move occurs because `c` has type `Config`, which does not implement the `Copy` trait

Summary

  • &self only reads. The original stays usable, and it's the right default.
  • &mut self mutates. The original stays usable, but the caller needs let mut.
  • self takes ownership. Use it to take a value out, to convert, or to prevent a second call.
  • A move means the value goes to a new owner and the original name can no longer be used. The value isn't destroyed and doesn't become undefined. It's a compile-time rule, so there's no runtime cost.
  • A move is what assignment does by default; it happens in let b = a too.
  • If it gets confusing, separate place and value: what moves is the value, and the place stays in the original struct.