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.namerefers to two things at once: the name slot insidec— 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?
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
}
| receiver | what it can do | original after the call | requirement on the caller |
|---|---|---|---|
&self | read only | still usable (call it as often as you like) | none |
&mut self | mutate fields | still usable | the variable must be let mut |
self | take a value out / convert / consume | unusable (moved) | the original can't be used once you call it |
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.
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).
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 undefined | a moved variable in Rust | |
|---|---|---|
| is there a value | yes, an actual value | the value went to its new owner; this variable has no value |
| when is it decided | runtime | compile time |
| what if you read it | you get undefined and the program keeps running | the compile is rejected; no binary is produced at all |
| when the problem shows up | much later, at runtime | at 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.
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
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.
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.
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
impl Conn { fn close(self) { println!("closed"); } }
c.close();
c.close(); // the second one
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
cstruct. A memory location. - value: the data in that slot.
"hello".
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 inc, soccan still be used.self= moving the value to a different place. The original slot is left empty, and the nameccan 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.
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.
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
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
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.
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 takesself→ all ofcmoves →portis blocked toolet 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.
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
impl Drop for Config { fn drop(&mut self) {} }
let n = c.name;
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.
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.
#[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.
move occurs because `c` has type `Config`, which does not implement the `Copy` trait
Summary
&selfonly reads. The original stays usable, and it's the right default.&mut selfmutates. The original stays usable, but the caller needslet mut.selftakes 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 = atoo. - If it gets confusing, separate place and value: what moves is the value, and the place stays in the original struct.