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

(수정됨: 2026년 7월 22일)

Every time I write a Rust method I stall on the same choice: &self, &mut self, or self. For a while I assumed it was a matter of taste — use &mut self if you need to mutate, &self otherwise, and self when you want to be a bit more forceful about it. It isn't.

This post covers what actually separates the three receivers, what a move really does, and why you'd reach for self at all when it costs you access to the original. 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 out of 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 one, and the name that used to own it can no longer touch it.
  • place / value: writing c.name points at two things at once. The name slot inside c — the place — and the data sitting in that slot — the value. The Rust Reference splits these as place expressions and value expressions. This distinction is the spine of the whole post.

1. The three receivers are not a style choice

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

They do different things. There is exactly one fork in the road: is the instance still alive 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 — swallows the instance
}
receiverwhat it can dooriginal after the callrequirement on the caller
&selfread onlyalive (call it as often as you like)none
&mut selfmutate fieldsalivethe variable must be let mut
selftake a value out / convert / consumeinvalidated (moved)the original is sealed the moment you call
Rust
let c = Config { name: "hello".into() };

let a = c.peek();  // &self — just a loan, c is still alive
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 isn't free either. 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 };

Reaching for &mut self on a method that changes nothing forces an unnecessary mut on everyone who uses the type. &self is the default; you move up only when you have to.

2. A move is not destruction

When I saw that calling a self method locked me out of the original, my first thought was that the thing had been taken apart. Was the value just gone? Was this like JavaScript's undefined? Neither.

What gets taken apart is not the value. It's the original binding's permission to use it.

Rust
let s = c.take_name();
// the value "hello" → perfectly alive. It just belongs to s now.
// the variable c    → the compiler stamps it "do not use".

The value only changed owners; nothing was destroyed. Rust blocks the original side to prevent a double free — if two names each believe they own the same value, cleanup happens twice. One owner, so every other name gets shut off.

undefined lives on a different layer entirely.

JS undefineda moved variable in Rust
is there a valueyes, an actual onethe value left with its new owner; there is nothing here to talk about
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 does it bitemuch later, as a runtime crashthe code that would crash never ships

A moved variable doesn't hold some special "empty" value. The compiler simply records in its ledger that this name is invalid from this point on. That's why it costs nothing at runtime — the move leaves no trace in the binary.

3. So why use self at all?

Even after all that it kept nagging at me. Why give up the original on purpose? Was this a performance thing, some kind of optimization? Both were the wrong question.

One frame has to go first. In Rust a move is not an optimization technique — it's the default assignment semantics. No method, no self, just a plain binding, and a move still happens.

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) isn't a special technique. It's 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 runs the other way: relocation is the default, and copying (clone()) is what you ask for explicitly.

The question has to flip too. Not "why use it when it destroys the original" but "what do I get in exchange for giving up the original name?" Three things.

You inherit 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 couldn't do this even in principle. You can't smuggle something out of a loan, so copying is the only option left.

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

Turning 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 precisely because the original name was given up — if that name stayed alive, two names would own one buffer, and the compiler could never 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`

Closing an already-closed connection doesn't compile. Instead of a runtime flag and if self.closed { panic!() }, the case was deleted at the type level. Here the sealing of the original isn't a side effect — it is the feature you wanted.

You didn't need the original anyway

Once every field has been taken out, the empty shell is useless; a builder has nothing to say after .build(). Make those &self and you force the caller to clone() an original nobody was going to use.

Framing it as performance gets it wrong

A move isn't free. A String itself is 24 bytes — pointer, length, capacity — and those 24 bytes may well be copied on the stack when it moves. What doesn't move is the actual data on the heap. Moving large structs around repeatedly appears to leave some of that stack copying behind (it's generally said to be optimized away, though I didn't verify that here). The accurate claim isn't "moves are fast" but "the heap data is never reallocated."

And reaching for self for performance when the ownership story isn't true backfires. The caller still wants the original, so they clone() it, and you've added a copy rather than removed one.

self is not an optimization choice; it's a lifetime declaration. You use it when "this instance is done here" is a true statement, and the performance follows from the declaration being right. If the original is still needed afterwards, use &self. Nothing is forced — you're choosing.

4. What moves is the value, not the place

This is where it clicked. I asked whether I was getting c.name itself or the value that had been sitting in c.name — and that turned out to be right.

c.name mixes two things together.

  • 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 relocates. The slot belongs to c and stays
s:              [ "hello" ]   ← s owns this value now

A move in Rust is always the movement of a value, never of a place. Grab that one sentence and the previous sections collapse into it.

  • &self = showing the place for a moment. The value stays in c, so c stays alive.
  • self = relocating the value to a different place. The old slot empties out and the name c is sealed.

into_bytes inheriting the heap address in section 3 is the same story. What moved wasn't a place — it was the pointer value that had been sitting in one.

5. mut attaches to the binding, not to the value

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 what c was doesn't matter.

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 transfers ownership and nothing else. Mutability is decided anew by the variable receiving it: let mut s and you can mutate, let s and you can't. There is no "I am mutable" tag riding along with the data. mut is a property of neither the type nor the value — it's the permission a given name currently holds.

And mutating s isn't "mutating c.name" in any sense. The value belongs to s now, and there is no c.name left to mutate. The place/value split from section 4 is doing the work again: the place stayed with c but is empty, and the value is in s.

6. self swallows the whole struct, even for one field

With both name and port on c, I figured calling take_name() would only carry off name and leave port behind. 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. The unit of the transfer is set by the receiver, not by the return type. Returning just name still moves the whole struct, the "do not use" stamp lands on the name c as a whole, and port goes with it. The port that never left is quietly dropped when the method ends.

The exception — skip the method and only the field relocates

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

That's a partial move. Only the field that left 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 names c.name, not c. The ledger is kept per field.

  • c.take_name() → the method swallows self → all of c moves → port blocked too
  • let n = c.name → only the field relocates → partial move → the other fields survive

The fork is whether you go through a method or touch the field directly. If you want to get several fields out and keep them all, hand them over at once 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 — Drop shuts partial moves down

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, at the moment the value goes away. A half-emptied struct can't be handed to it, so the compiler blocks the taking side first. A partial move is only allowed when nobody will ever look at the struct as a whole again.

7. Copy types never move in the first place

Copy marks a type as one where copying the bits yields a complete duplicate. On assignment or argument passing, instead of invalidating the original, Rust makes one more copy. Not a relocation of the value — a duplication of it.

Within one struct, field types can split it.

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 and yet both c.port and c.name are still there — the exact mirror image of let n = c.name sealing c.name back in section 6. Identical syntax; whether it relocates or duplicates depends on the type.

If the whole struct is Copy, even a self receiver leaves the original standing.

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 precise form of "taking self seals the original" is "when the type is not Copy." The fn close(self) design from section 3, the one that blocks a second call, does not hold for a Copy type.

The error message in section 6 was already spelling out that condition.

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

To put it back together

  • &self says "let me have a look." The original stays; this is where you start.
  • &mut self says "let me fix it." The original stays, but the caller now owes you a let mut.
  • self says "I'm taking this." For getting a value out, for converting, for blocking a second call.
  • A move is a value relocating plus the original name being sealed. Not destruction, not undefined. The seal is a static rule living in the compiler's ledger, so it costs nothing at runtime.
  • And a move isn't an exceptional event — it's the default. It's already there in let b = a.

When I lose the thread, I go back to the place/value split. What moves is always the value; the place stays behind in the struct it belonged to. The thing I took for a style choice turned out to be a declaration about lifetime — am I ending this instance here or not — with performance as the reward for getting that declaration right.