Skip to content
C++
Language
since C++17
Intermediate

Copy Elision and NRVO

C++17 guarantees prvalue copy elision. NRVO elides named-local returns. Never std::move a local in return — it disables NRVO.

Copy Elisionsince C++17

An optimization — mandatory for prvalue initializers since C++17, optional for named returns — where the compiler constructs an object directly in its final storage location, eliding any copy or move entirely.

Overview

Copy elision existed as a permitted-but-optional optimization since C++98. C++17 changed the semantics fundamentally: for prvalue initializers, elision is now a language rule, not an optimization. The compiler is not permitted to emit a copy or move — even if you observe construction through side effects.

The mechanism is a reworked value-category model. Before C++17, a prvalue was a temporary object that happened to live somewhere. In C++17, a prvalue is an initialization instruction — a recipe with no identity and no storage. It is not materialized into an object until the language actually requires one (binding to a reference, applying &, discarding with a comma operator, etc.). At the point of materialization, the object is constructed in-place where it is needed.

This means Widget{} in a return expression is not "a Widget being copied into the return slot" — it directly is the initialization of the return slot. There is no intermediate object to copy or move.

Two distinct mechanisms fall under "copy elision":

  • RVO (Return Value Optimization): returning a prvalue. Guaranteed since C++17.
  • NRVO (Named Return Value Optimization): returning a named local variable. Optional, but universally applied by GCC, Clang, and MSVC under optimization.

Examples

Guaranteed Elision — Prvalue Returns (C++17)

cpp
struct Widget {
    Widget()                    { std::println("ctor");       }  // std::println: C++23
    Widget(const Widget&)       { std::println("copy ctor");  }
    Widget(Widget&&) noexcept   { std::println("move ctor");  }  // noexcept: C++11
};

Widget make() {
    return Widget{};  // prvalue — guaranteed copy elision (C++17)
}

Widget w = make();
// Output: "ctor" — exactly one construction; no copy, no move

Because this is a language rule rather than an optimization, it works even with deleted copy and move constructors:

cpp
struct Immovable {
    Immovable() = default;
    Immovable(const Immovable&) = delete;
    Immovable(Immovable&&)      = delete;
};

Immovable make_immovable() {
    return Immovable{};  // C++17: well-formed — prvalue never materializes as temporary
}

Immovable x = make_immovable();  // C++17: OK
// C++14: ill-formed — required a movable or copyable type

The same prvalue rule applies to function arguments:

cpp
void sink(Widget w);
sink(Widget{});  // Widget{} constructed directly as parameter 'w' — C++17 guaranteed

Named Return Value Optimization

NRVO fires when a function returns a named local variable of the same type as the return type, and the compiler can determine at compile time that only one candidate variable exists. The local is constructed directly in the caller's return-value storage:

cpp
std::vector<int> build_primes(int limit) {
    std::vector<int> primes;   // NRVO candidate: named local, same return type
    for (int n = 2; n <= limit; ++n)
        if (is_prime(n)) primes.push_back(n);
    return primes;  // NRVO: primes lives in caller's storage from the start
}

auto p = build_primes(1000);
// With NRVO:    one construction of the vector — zero copies, zero moves
// Without NRVO: one construction + one move construction

To observe the difference, disable elision at the compiler level: -fno-elide-constructors (GCC/Clang) or /Zc:nrvo- (MSVC; on by default under /O2 and /std:c++20 or later).

Conditions That Block NRVO

cpp
// Multiple named candidates — compiler cannot choose one at compile time
Widget make_either(bool cond) {
    Widget a, b;
    return cond ? a : b;  // two candidates: NRVO cannot apply
    // Fallback: implicit move (C++11), one move ctor call
}

// Returning a base subobject — type mismatch, NRVO blocked
struct Base {};
struct Derived : Base { int extra; };

Base slice_me() {
    Derived d;
    return d;   // slicing: type mismatch, not eligible for NRVO
}

// Volatile locals — NRVO suppressed by all major compilers
Widget volatile_local() {
    volatile Widget w;
    return w;  // volatile: not a valid NRVO candidate
}

Implicit Move Fallback (C++11 through C++23)

When NRVO does not apply, the standard requires the compiler to treat the named return expression as an rvalue — applying an implicit move rather than a copy. This rule has been refined across standards:

cpp
// C++11: implicit move from named local variables in return
Widget fallback_move() {
    Widget w;
    /* ... condition prevents NRVO ... */
    return w;  // C++11: implicit move; same effect as return std::move(w)
}

// C++20: implicit move extended to rvalue-reference locals and throw expressions
void rethrow_extended(std::exception_ptr eptr) {
    try { std::rethrow_exception(eptr); }
    catch (std::runtime_error&& e) {
        throw e;  // C++20: implicit move from caught rvalue ref
    }
}

// C++23: implicit move from function parameters (eliminates last explicit std::move)
std::vector<int> append_c23(std::vector<int> v, int x) {
    v.push_back(x);
    return v;  // C++23: parameter implicitly moved — no explicit std::move needed
    // C++11-C++20: needed return std::move(v) to avoid a copy
}

NRVO In Depth

NRVO works because the calling convention reserves space in the caller's stack frame (or a register slot for small types) for the return value before the call happens. The callee receives a hidden pointer to that storage and constructs the local directly into it. The local variable and the return slot are the same object.

For NRVO to fire, the compiler must be able to identify a single, unambiguous named variable at the point where it chooses to allocate the local. The criteria in practice:

  1. The return expression names a local variable (not a parameter, not a member, not the result of an expression).
  2. The local has the same cv-unqualified type as the function's return type.
  3. There is at most one such candidate reachable at any return statement.

When NRVO fires — the canonical form:

cpp
// Single named local, single return — NRVO fires on every major compiler at -O1+
std::string canonical_nrvo() {
    std::string result;          // allocated in caller's storage
    result.reserve(256);
    result += "Hello, ";
    result += "world";
    return result;               // no move; result IS the caller's object
}

When NRVO does not fire — multiple paths, different variables:

cpp
// Two separately constructed locals — compiler cannot unify their storage
std::string nrvo_blocked_two_locals(bool upper) {
    std::string lo = build_lower();
    std::string hi = build_upper();
    return upper ? hi : lo;  // NRVO blocked; implicit move fires instead
}

When NRVO does not fire — returning a function parameter:

Parameters are passed into the function by the caller; their storage is managed by the calling convention, not the callee. The callee cannot retroactively redirect a parameter to live in the return slot.

cpp
// C++11–C++20: parameter is NOT an NRVO candidate
std::vector<int> append_pre23(std::vector<int> v, int x) {
    v.push_back(x);
    return v;          // NRVO blocked; C++11-C++20: copy! must use std::move(v)
    // C++23: implicit move applies to parameters — copy avoided without std::move
}

When NRVO does not fire — returning a member of a local struct:

cpp
struct Box { std::string value; };

std::string extract(Box b) {
    return b.value;  // subobject, not the variable itself — NRVO does not apply
}

The compiler may still move b.value here (implicit move rules are broad), but it cannot place b.value directly in the return slot because b and b.value have different addresses.

The "Never std::move a Local Return" Rule

return std::move(local) disables NRVO and is always wrong for local variables.

std::move converts an lvalue into an xvalue. NRVO requires the return operand to be an lvalue naming the candidate variable. Once you wrap it in std::move, you have an xvalue — a different value category — and the NRVO precondition is broken. The compiler is forced to call the move constructor instead.

The practical consequence: you get one extra move construction that you would not have gotten with a plain return local.

cpp
struct Heavy {
    Heavy()             { std::println("ctor");      }
    Heavy(Heavy&&) noexcept { std::println("move ctor"); }
};

// With NRVO (preferred):
Heavy make_good() {
    Heavy h;
    return h;      // NRVO: output is "ctor" only
}

// With explicit move (wrong for locals):
Heavy make_bad() {
    Heavy h;
    return std::move(h);  // NRVO blocked: output is "ctor" then "move ctor"
}

You can confirm this with Clang's -Wpessimizing-move (enabled by -Wextra) or GCC's equivalent. The compiler will warn:

cpp
warning: moving a local object in a return statement prevents copy elision [-Wpessimizing-move]
    return std::move(h);
           ^~~~~~~~~~~~

The correct mental model: "return local" means "NRVO if possible, implicit move if not." "return std::move(local)" means "implicit move, always." The first is never worse and is usually better.

When explicit std::move in return IS correct:

There are two legitimate cases:

  1. Returning a function parameter in C++11–C++20 (where implicit move does not apply to parameters):
cpp
// C++11-C++20: without std::move, this copies the vector
std::vector<int> with_extra_cpp20(std::vector<int> v, int x) {
    v.push_back(x);
    return std::move(v);  // C++11-C++20: correct — avoids copy of parameter
    // C++23: plain `return v` is equivalent; std::move no longer needed
}
  1. Returning one of several variables when you have intentionally chosen which one and NRVO cannot apply:
cpp
// Explicitly moving a specific variable from a conditional — NRVO cannot fire anyway
std::string pick_and_move(bool flag, std::string a, std::string b) {
    if (flag) return std::move(a);
    return std::move(b);
    // NRVO was already blocked (two candidates); std::move avoids copy of parameters
    // In C++23 these are implicitly moved and std::move is redundant but not harmful
}

C++23 Implicit Move Improvements

C++23 (P2266) broadened the set of expressions treated as rvalues on return and throw, eliminating the last cases where a programmer needed to write an explicit std::move.

Before C++23 (C++20 behavior): Implicit move applied to named locals and rvalue-reference locals in return and throw. Function parameters were excluded — returning a parameter by value caused a copy.

C++23: Implicit move now applies to function parameters and catch-clause variables on return and throw. The two-step overload resolution (try move, fall back to copy) is replaced with a single direct move attempt.

cpp
// C++11-C++20: parameter 'v' is an lvalue; returning it copies
// Must write: return std::move(v);
std::vector<int> prepend_cpp20(std::vector<int> v, int front) {
    v.insert(v.begin(), front);
    return std::move(v);  // C++11-C++20: explicit std::move required
}

// C++23: parameter 'v' is implicitly moved on return
std::vector<int> prepend_cpp23(std::vector<int> v, int front) {
    v.insert(v.begin(), front);
    return v;             // C++23: implicitly moved — no std::move needed
}

The same rule applies to catch-clause variables:

cpp
// C++20: must explicitly move exception out of catch clause
std::exception_ptr capture_cpp20() {
    try { /* ... */ }
    catch (std::runtime_error e) {
        return std::make_exception_ptr(std::move(e));  // C++20: explicit move
    }
    return nullptr;
}

// C++23: implicit move from catch variable
std::exception_ptr capture_cpp23() {
    try { /* ... */ }
    catch (std::runtime_error e) {
        return std::make_exception_ptr(e);  // C++23: implicitly moved
    }
    return nullptr;
}

Summary of implicit move evolution:

StandardImplicit move applies to
C++11Named local variables in return
C++20+ rvalue-reference locals; + throw operands
C++23+ function parameters; + catch-clause variables

Guaranteed Elision and Deleted Move Constructors

Before C++17, Widget w = factory() required Widget to have an accessible copy or move constructor, even if the compiler planned to elide it. The standard required the expression to be well-formed as-if a copy or move would occur, then allowed the compiler to skip it.

Since C++17, the situation changed fundamentally. A prvalue is not a temporary object — it is an initialization instruction. There is no copy or move to be "elided" because none was ever implied. The move constructor is simply never consulted.

cpp
struct NoCopyNoMove {
    NoCopyNoMove(int x, int y) : x_(x), y_(y) {}
    NoCopyNoMove(const NoCopyNoMove&) = delete;
    NoCopyNoMove(NoCopyNoMove&&)      = delete;

    int x_, y_;
};

// C++14: ill-formed — move constructor is deleted, so this cannot compile
// C++17: well-formed — prvalue returned directly; no move ctor consulted
NoCopyNoMove factory() {
    return NoCopyNoMove{3, 4};  // prvalue — guaranteed elision (C++17)
}

NoCopyNoMove w = factory();  // C++17: OK even with deleted move ctor

This matters for types that are intentionally non-movable by design — mutex wrappers, arena allocators, objects with stable addresses. Before C++17, you could only construct them in place (e.g., via placement new or std::in_place). Since C++17, factory functions returning prvalues work without any ceremony.

Aggregate Initialization and Elision

Aggregate initialization with braced init creates a prvalue, so guaranteed elision applies in C++17 and later. The braced expression Widget{1, 2} is not a temporary being copied into w — it is the direct initialization of w.

cpp
struct Point {
    int x, y;
    // No constructors declared — aggregate (C++11+)
};

Point p = Point{3, 4};  // C++17: guaranteed elision — no copy, no move
                         // equivalent to: Point p{3, 4};

This is useful for non-copyable aggregates:

cpp
struct Handle {
    int fd;
    Handle(const Handle&) = delete;
    Handle(Handle&&)      = delete;
};

Handle open_handle(int flags) {
    return Handle{flags};  // C++17: prvalue — guaranteed elision; compiles
}

Handle h = open_handle(O_RDONLY);  // OK in C++17+

It also combines with auto and structured bindings cleanly:

cpp
auto [x, y] = Point{10, 20};  // C++17: guaranteed elision + structured binding
                                // Point is constructed directly; no intermediate

Exception Semantics and Elision

Guaranteed elision and exception safety are orthogonal — construction always happens exactly once in-place.

When guaranteed elision applies and the constructor throws, the behavior is as if the object was being constructed directly at its destination. There is no partial copy to clean up. The stack unwind is clean:

cpp
struct MayThrow {
    MayThrow() { throw std::runtime_error("oops"); }
};

try {
    MayThrow m = MayThrow{};  // C++17: direct construction — throws, no partial copy
} catch (const std::runtime_error& e) {
    // Caught here; no dangling partial object exists
    std::println("{}", e.what());  // "oops"
}

The same applies when the thrown exception propagates through a function chain:

cpp
MayThrow factory() {
    return MayThrow{};   // prvalue; if ctor throws, caller's storage is never dirtied
}

try {
    MayThrow m = factory();  // exception propagates cleanly
} catch (...) { /* safe */ }

For NRVO (not guaranteed), the object is already in the caller's storage when the constructor runs. If the constructor throws, the partial object was placed there, but the caller's stack frame is being unwound immediately — the object's destructor is called as the frame exits. This is safe because stack unwinding always runs destructors for fully or partially constructed objects at any scope level.

Pessimistic Copy: Containers

push_back(T{}) prevents guaranteed elision into the vector element; emplace_back() does not.

When you call vec.push_back(Widget{}), the signature is push_back(const T&) or push_back(T&&). Even though Widget{} is a prvalue, guaranteed elision only applies when the prvalue directly initializes an object. Here, the prvalue initializes a temporary bound to the function parameter — the temporary is then moved into the vector's internal storage. You get one construction (the temporary) plus one move construction (into the buffer), totaling two special-member-function calls.

emplace_back takes the constructor arguments directly and forwards them to construct the element in-place inside the vector's buffer. There is no intermediate object.

cpp
struct Tracked {
    Tracked()             { std::println("ctor");      }
    Tracked(Tracked&&) noexcept { std::println("move ctor"); }
};

std::vector<Tracked> v;
v.reserve(2);  // prevent reallocation to isolate the counts

v.push_back(Tracked{});
// Output:
//   ctor       <- temporary Widget{}
//   move ctor  <- move into vector buffer

v.emplace_back();
// Output:
//   ctor       <- constructed directly in vector buffer

The difference is significant for types where moving is not O(1) — for example, fixed-capacity stack-allocated buffers, or types with non-trivial move constructors:

cpp
// Prefer emplace_back when constructing a new element from arguments
v.emplace_back(/* constructor args */);

// push_back is fine when you already have an object to move or copy
Tracked t;
v.push_back(std::move(t));  // explicit move of existing object — one move ctor call

Note that since C++17, push_back(T{}) still costs a construction plus a move (guaranteed elision applies to the temporary binding, not to the in-buffer copy). emplace_back() eliminates this extra move.

Observable Side Effects and the "As-If" Rule

Before C++17, copy elision was an exception to the as-if rule: the compiler was allowed to suppress a copy/move constructor call even if that constructor had observable side effects (such as printing or incrementing a counter). This was technically permitted but could surprise programmers who relied on the side effect for correctness.

cpp
// Pre-C++17 behavior: copy elision was optional; side effects could disappear
struct Noisy {
    Noisy()             { std::println("ctor");      }
    Noisy(Noisy&&) noexcept { std::println("move ctor"); }
};

Noisy make_pre17() {
    return Noisy{};  // before C++17: compiler MAY elide move; side effect MAY not fire
}

A test that counted constructor invocations could produce different answers on different compilers, or even different optimization levels on the same compiler. This made it difficult to write portable tests that verified construction semantics.

Since C++17, for prvalue initializers the situation is categorically different. The copy or move constructor is not "elided" — it was never required in the first place. The prvalue is an initialization instruction that directly constructs its destination. There is no suppressed side effect because there is no side effect to suppress: the copy/move constructor simply does not run.

cpp
// C++17: no side effect question — the copy/move constructor never exists in this context
Noisy make_c17() {
    return Noisy{};  // C++17: one "ctor" call, period — not an optimization, a language rule
}

Noisy w = make_c17();
// "ctor" — guaranteed, on every conforming compiler, at every optimization level

This distinction matters when writing tests that verify construction semantics, and when auditing code that relies on constructor side effects for initialization ordering. In C++17, you can write reliable tests:

cpp
int ctor_count = 0;

struct Counted {
    Counted() { ++ctor_count; }
    Counted(const Counted&) { ++ctor_count; }
};

Counted factory() { return Counted{}; }

Counted c = factory();
assert(ctor_count == 1);  // C++17: always passes; C++14: implementation-defined

Best Practices

Never add std::move to a return of a local variable. It converts the expression from an NRVO candidate (lvalue) to a move candidate (xvalue), explicitly disabling NRVO. The resulting code is always at least as expensive as writing return local and often more so:

cpp
// Wrong: std::move disables NRVO, forces move ctor
std::string wrong() {
    std::string s = assemble_large_string();
    return std::move(s);  // NRVO blocked — move ctor executes
}

// Right: NRVO fires, or implicit move fires if NRVO is blocked
std::string right() {
    std::string s = assemble_large_string();
    return s;  // never worse than std::move version; usually better
}

Structure functions to have a single named return path. NRVO is most reliable when the compiler can identify one unambiguous candidate:

cpp
// Harder for NRVO — two independently constructed objects
std::string format_v1(bool upper) {
    std::string lo = build_lower();
    std::string hi = build_upper();
    return upper ? hi : lo;  // two candidates, NRVO blocked
}

// Easier for NRVO — one named result, mutated in-place
std::string format_v2(bool upper) {
    std::string result;
    if (upper) result = build_upper();
    else       result = build_lower();
    return result;  // single NRVO candidate
}

Use the IIFE lambda pattern to preserve NRVO for complex initialization:

cpp
// NRVO applies within the lambda body, result materialized at call site (C++17)
const auto table = [&]() -> std::unordered_map<std::string, int> {
    std::unordered_map<std::string, int> m;
    m.reserve(entries.size());
    for (auto& e : entries) m.emplace(e.key, e.value);
    return m;
}();

Prefer emplace_back over push_back(T{}) when constructing new elements. Guaranteed elision does not reach inside container internals, so constructing in-place with emplace_back eliminates the extra move.

In C++23, you can drop std::move when returning function parameters. If you are targeting C++23 or later, return v for a std::vector<int> v parameter is now implicitly moved. Remove the explicit std::move to let the code reflect the simplified rules and avoid confusion.

Common Pitfalls

Expecting NRVO in debug builds. MSVC disables NRVO at /Od (the debug default). GCC and Clang may also skip it at -O0. If you're measuring constructor calls in a debug build and seeing an extra move, that's expected — the implicit move fallback ensures correctness regardless. Enable at least -O1 to observe realistic elision behavior.

Treating std::move in return as an optimization. This misconception is widespread. Compilers emit a warning for exactly this case: Clang's -Wpessimizing-move fires when std::move in a return prevents copy elision. Trust the compiler; add std::move in return only when returning an rvalue-reference parameter in C++11–C++20 code.

Returning parameters expecting NRVO (pre-C++23). Function parameters are not eligible for NRVO. In C++11–C++20, omitting std::move when returning a parameter results in a copy, not a move:

cpp
// C++11-C++20: must std::move to avoid copying the parameter
std::vector<int> with_extra(std::vector<int> v, int x) {
    v.push_back(x);
    return std::move(v);  // C++11-C++20: necessary
    // return v;          // C++23: implicit move from parameter — same cost
}

Relying on throw/catch copy elision. When an exception object is thrown and caught by value, copy elision is permitted but not guaranteed. In practice, compilers elide it for simple throw expressions, but the behavior is less predictable than return-value elision. Catch by const& unless you need to modify the exception.

cpp
try {
    throw std::runtime_error("detail");  // may construct directly in catch slot
} catch (const std::runtime_error& e) { // prefer const ref — elision irrelevant
    handle(e);
}

Assuming non-movable types cannot be returned pre-C++17. In C++14 and earlier, return NonMovable{} from a factory function is ill-formed because the language required a move constructor to be available (even if elided). The C++17 prvalue-as-initialization-instruction change is what makes this work — upgrading the language standard is the fix, not adding a dummy move constructor.

Confusing NRVO eligibility with guaranteed elision. They are separate mechanisms. Guaranteed elision is about prvalues (unnamed temporaries). NRVO is about named locals. return Widget{} is guaranteed elision; return w (where w is a local) is NRVO. Disabling NRVO with -fno-elide-constructors does not affect guaranteed elision in C++17.

See Also