Adding std::move to a return statement looks like an optimization. It is actually the opposite. When you return a local variable directly, the compiler constructs it in place at the call site, no copy, no move, zero overhead. The moment you write return std::move(a), you hand the compiler a complex expression instead of a plain local variable, and the optimization is gone.
What RVO and NRVO Actually Are
Return Value Optimization (RVO) eliminates the copy or move that would otherwise happen when a function returns an object by value. Instead of constructing the object inside the function and then copying or moving it out to the caller, the compiler constructs it directly in the memory the caller reserved for the result. No copy, no move, the object only ever exists in one place.
RVO comes in two forms, and the distinction between them matters for how reliably the optimization applies.
Unnamed RVO covers returning a prvalue directly: a temporary, a constructor call, anything that has no name. This has been mandatory since C++17. The standard requires the compiler to apply it, there is no opt-out, no “as-if” wiggle room. If a function returns a prvalue, the construction happens in place at the call site, guaranteed.
MyObject make() {
return MyObject{}; // unnamed RVO: mandatory since C++17
}
Named Return Value Optimization (NRVO) covers returning a named local variable. This is optional, the standard permits it but does not require it. In practice, all major compilers apply it in the straightforward case.
MyObject make() {
MyObject a;
return a; // NRVO: optional, but applied in this case
}
The practical difference: unnamed RVO is a guarantee you can rely on unconditionally. NRVO is a guarantee you can rely on conditionally, as long as you do not defeat it.
How NRVO Gets Defeated
NRVO requires the compiler to see a single, unambiguous named local variable being returned. The moment that condition is not met, it gives up and falls back to a move.
The most common case: std::move on a return statement.
MyObject make_no_rvo() {
MyObject a;
return std::move(a); // NRVO defeated: std::move produces an rvalue reference, not a named local
}
std::move is a cast to rvalue reference. It produces a different expression category entirely, not the plain named local NRVO requires. The compiler cannot apply the optimization, falls back to move construction, and you pay for a move that should never have existed. The object is constructed inside the function, then moved to the caller’s storage. One extra constructor call per return, every time this function is called.
This is the trap: std::move on a return statement looks like it is helping. It is not. It is getting in the way.
The second common case: multiple return paths returning different named objects.
MyObject make_conditional(bool flag) {
MyObject a;
MyObject b;
if (flag) return a;
return b; // NRVO defeated: compiler cannot know at compile time which object to construct in place
}
NRVO works by the compiler deciding ahead of time exactly where to construct the object. If two different named locals might be returned depending on a runtime condition, the compiler cannot make that decision at compile time and gives up on NRVO for the whole function. Returning a prvalue from each branch instead keeps the optimization:
MyObject make_conditional(bool flag) {
if (flag) return MyObject{/* ... */};
return MyObject{/* ... */}; // unnamed RVO: mandatory
}
Seeing it at Runtime
MyObject is instrumented to print which constructor fires:
struct MyObject {
MyObject() { printf("construct\n"); }
MyObject(const MyObject&) { printf("copy\n"); }
MyObject(MyObject&&) noexcept { printf("move\n"); }
};
make_rvo returns a directly, NRVO applies, only the constructor fires. make_no_rvo wraps it in std::move, NRVO is defeated, and a move fires on top of the construction. The difference is visible at runtime without reading any assembly.
Seeing it at the Instruction Level
codegen.cpp is compiled to assembly and inspected with objdump. The result matches exactly what the runtime output showed, now visible at the instruction level.
make_rvo():
call puts ; construct only, NRVO applied
ret
make_no_rvo():
call puts ; construct
call puts ; move — this call should not exist
ret
One extra puts call in make_no_rvo. That is the move constructor, present only because std::move blocked NRVO from applying. This is the overhead that is invisible in source code but visible immediately in the disassembly and in profiler output on a hot path.
The -Wpessimizing-move Flag
-Wpessimizing-move has existed since GCC 9 and ships as part of -Wall, no separate flag needed in a normal build. It fires specifically when a return statement wraps a local variable in std::move, which is the exact anti-pattern this post covers.
-Wnrvo (added in GCC 14) is a different warning and does not catch this case. -Wnrvo fires when NRVO was eligible under the standard’s elision rules, a plain return a; naming an automatic variable, but the compiler’s own analysis still could not perform it, usually from complicated control flow. return std::move(a); never reaches that check at all. The moment std::move is added, the return statement’s operand is a call expression, not a bare name, and the standard’s elision rule requires the operand to be the name itself. -Wnrvo has nothing to say about a case that was never a candidate in the first place but -Wpessimizing-move targets this specific mistake.
Disabling Elision: What RVO is Actually Saving You
-fno-elide-constructors forces GCC to disable all copy and move elision, including both unnamed RVO and NRVO. The standard allows an implementation to omit creating a temporary used only to initialize another object of the same type. This flag removes that permission entirely and requires the copy or move constructor to be called in all cases.
Running the same main.cpp with this flag shows what the code would do if the compiler were not applying NRVO. With elision disabled, make_rvo and make_no_rvo produce identical output. The move that NRVO normally eliminates in make_rvo is now visible. Both functions construct the object inside the function body and then move it to the caller’s storage. The only difference between them in a normal build is that NRVO saves make_rvo the move, and std::move in make_no_rvo prevents that saving from happening. With elision disabled, that distinction collapses entirely.
This flag is useful to make the cost of not having elision visible before going back to a normal build. It is not something to leave on.
What the Code Demonstrates
main.cpp shows the difference through runtime constructor output, making it visible without reading assembly. codegen.cpp is not meant to run, it is compiled and disassembled with objdump to confirm the same result at the instruction level.
Run: main.cpp
g++ -O2 -std=c++20 main.cpp -o main && ./main
-O2 is the standard optimization level used across this repo.
=== make_rvo ===
construct
=== make_no_rvo ===
construct
move
make_rvo prints one line, the constructor and nothing else, confirming NRVO applied and the object was built directly in place. make_no_rvo prints the constructor followed by a move, the exact call that std::move introduced and that should not exist.
Run: -Wpessimizing-move
g++ -O2 -std=c++20 -Wpessimizing-move main.cpp -o main
-Wpessimizing-move is a warning flag, not an optimization flag, so it does not change codegen, it only reports the std::move in the return statement. This also fires with plain -Wall, since -Wpessimizing-move is included in it by default.
main.cpp: In function 'MyObject make_no_rvo()':
main.cpp:34:21: warning: moving a local object in a return statement prevents copy elision [-Wpessimizing-move]
34 | return std::move(a); // NRVO defeated: move fires unnecessarily
| ~~~~~~~~~^~~
main.cpp:34:21: note: remove 'std::move' call
The warning points directly at the return std::move(a); line, and the compiler’s own note tells you the fix: remove the std::move call. Nothing is reported for make_rvo, since there is nothing wrong with it.
Run: -fno-elide-constructors
g++ -O2 -std=c++20 -fno-elide-constructors main.cpp -o main && ./main
-fno-elide-constructors disables all copy and move elision. It does not affect the correctness of the program, only the number of constructor calls. The output shows what the code would do without the optimization.
=== make_rvo ===
construct
move
=== make_no_rvo ===
construct
move
Both functions produce identical output. With elision disabled, make_rvo now pays the same move cost as make_no_rvo. The difference between them in a normal build is exactly one constructor call, which NRVO eliminates in make_rvo and std::move prevents in make_no_rvo.
Run: codegen.cpp
g++ -O2 -std=c++20 -c codegen.cpp -o codegen.o
objdump -d -M intel --no-show-raw-insn codegen.o | grep -A 10 "make_rvo\|make_no_rvo"
-c compiles to an object file without linking, since this file has no main and is not meant to run. -M intel selects Intel syntax. --no-show-raw-insn hides the raw instruction bytes. The grep keeps both functions and the instructions immediately following each label.
0000000000000000 <make_rvo()>:
0: push rbp
1: mov rbp,rsp
4: push rbx
5: mov rbx,rdi
8: lea rdi,[rip+0x0] # f <make_rvo()+0xf>
f: sub rsp,0x8
13: call 18 <make_rvo()+0x18> <-- one call, construct only
18: mov rax,rbx
1b: mov rbx,QWORD PTR [rbp-0x8]
1f: leave
20: ret
0000000000000030 <make_no_rvo()>:
30: push rbp
31: mov rbp,rsp
34: push rbx
35: mov rbx,rdi
38: lea rdi,[rip+0x0] # 3f <make_no_rvo()+0xf>
3f: sub rsp,0x8
43: call 48 <make_no_rvo()+0x18> <-- construct
48: lea rdi,[rip+0x0] # 4f <make_no_rvo()+0x1f>
4f: call 54 <make_no_rvo()+0x24> <-- move, should not exist
54: mov rax,rbx
57: mov rbx,QWORD PTR [rbp-0x8]
5b: leave
5c: ret
make_rvo has one call in its body, the constructor. make_no_rvo has two, the constructor and a second call for the move that std::move forced into existence. Same shape as the runtime output above, confirmed at the instruction level.
Quick Reference
Coming from other languages
Most languages with garbage collection or automatic memory management handle return values through reference semantics by default, so this kind of copy and move elision question does not arise in the same way. Languages with value semantics and manual resource management face the same class of problem, and the typical answer is either mandatory elision everywhere or an optimizer that applies it silently. C++ exposes both mandatory elision (unnamed RVO since C++17) and optional-but-reliable elision (NRVO), which means the programmer has enough visibility to accidentally defeat it, which is exactly the failure mode this post covers.
The 90% mental model
Return a named local variable directly and NRVO applies in the straightforward case, no copy, no move, the object is constructed in place. Write return std::move(local) and NRVO is defeated, a move fires that should not exist. The compiler handles this correctly when left alone. std::move on a return statement is not a hint, it is an obstacle. -Wpessimizing-move is part of -Wall and will catch this on the first build. When elision is working, -fno-elide-constructors shows exactly what it is saving you from.