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

Structured Bindings

C++17 decomposition syntax that unpacks arrays, aggregates, pairs, and tuple-like types into named variables in a single declaration.

Structured Bindingssince C++17

A structured binding declaration introduces a set of named identifiers bound to the elements of an array, aggregate, or tuple-like type, replacing manual index access or std::tie unpacking with readable named variables.

Overview

Structured bindings decompose a compound object into named identifiers. The key mental model: auto [a, b] = expr; does not create independent variables. It creates a hidden anonymous object initialized from expr, and a and b are references into that object's elements. This explains all the surprising reference and const behaviors.

Before C++17, unpacking multi-value returns required either std::tie (C++11) into pre-declared variables, or explicit member access like .first/.second. Both are verbose and don't scale beyond two or three elements.

The compiler resolves decomposition by trying three cases in order:

  1. Array — if the declared type is T[N], each element binds in declaration order.
  2. Tuple-like protocol — if std::tuple_size<T> is a complete specialization, the compiler calls get<I>(e) for each index: member get<I>() first, then ADL get<I>(obj).
  3. Aggregate members — if the type is a class/struct with no user-provided constructor, no private or protected non-static data members, and no virtual functions, members bind in declaration order.

Syntax

cpp
auto        [a, b] = expr;   // anonymous copy of expr; a and b refer into the copy
auto&       [a, b] = expr;   // a and b refer directly into expr (must be lvalue)
const auto& [a, b] = expr;   // const refs into expr; also extends temporary lifetime
auto&&      [a, b] = expr;   // lvalue ref if expr is lvalue, rvalue ref if expr is rvalue

The cv-qualifiers and ref-qualifier apply to the anonymous entity as a whole — not to individual bindings. auto [const a, b] = expr; is ill-formed; you cannot make only some bindings const.

Examples

Range-for over maps

The most common use case: readable key/value names instead of .first/.second.

cpp
#include <map>
#include <string>
#include <print>  // C++23

std::map<std::string, int> scores{{"Alice", 95}, {"Bob", 87}, {"Carol", 92}};

for (const auto& [name, score] : scores) {  // C++17
    std::println("{}: {}", name, score);     // C++23
}

Multi-value returns

cpp
#include <tuple>
#include <string_view>

struct ParseResult { int count; double ratio; std::string_view unit; };

ParseResult parse(std::string_view input);

auto [count, ratio, unit] = parse("42 0.75 seconds");  // C++17
// Accessing getStats().count, getStats().ratio etc. without temporaries

insert / emplace return values

Standard containers returning std::pair<iterator, bool> are a natural fit:

cpp
#include <set>
#include <map>

std::set<int> seen;
auto [it, inserted] = seen.insert(42);  // C++17
if (!inserted) {
    // 42 was already present; *it points to the existing element
}

std::map<std::string, int> cache;
auto [pos, ok] = cache.try_emplace("key", expensive_compute());  // C++17

Init-statement in if / switch (C++17)

cpp
if (auto [it, ok] = myMap.emplace("session", generate_id()); ok) {  // C++17
    log_new_session(it->second);
} else {
    log_existing_session(it->second);
}

Reference bindings and mutation

cpp
std::pair<int, int> bounds{0, 100};

auto& [lo, hi] = bounds;  // lo and hi refer directly into bounds
lo = 5;                   // bounds.first is now 5
hi -= 10;                 // bounds.second is now 90

Bit-fields (C++20)

C++20 extended structured bindings to cover bit-field members. Because you cannot form a reference to a bit-field, the bindings are always value copies:

cpp
struct Flags {
    unsigned ready : 1;
    unsigned error : 1;
    unsigned code  : 6;
};

Flags f{1, 0, 42};
auto [ready, error, code] = f;  // C++20; bindings are copies, not refs
// Modifying ready/error/code does NOT affect f

Custom tuple protocol

Opt any type into structured bindings without making it an aggregate:

cpp
#include <cstdint>

struct Pixel { std::uint8_t r, g, b, a; };

template<> struct std::tuple_size<Pixel>
    : std::integral_constant<std::size_t, 4> {};

template<std::size_t I>
struct std::tuple_element<I, Pixel> { using type = std::uint8_t; };

template<std::size_t I>
std::uint8_t get(const Pixel& p) {
    if constexpr      (I == 0) return p.r;  // C++17 if constexpr
    else if constexpr (I == 1) return p.g;
    else if constexpr (I == 2) return p.b;
    else                       return p.a;
}

Pixel px{255, 128, 0, 255};
auto [r, g, b, a] = px;  // r=255, g=128, b=0, a=255

The compiler checks for a member get<I>() first; if absent, it falls back to an ADL lookup for a free function get<I>(obj).

Lambda capture (C++20)

Capturing structured bindings in lambda bodies was underspecified in C++17 — some implementations rejected it, others accepted it with subtly wrong semantics. C++20 (P1091) fully standardized this:

cpp
auto [x, y] = std::pair{3, 4};

// C++17: capturing x and y is implementation-defined on some compilers
// C++20: fully specified and portable
auto dist_sq = [x, y]() { return x*x + y*y; };  // C++20

// Capture by reference also works C++20+:
auto increment = [&x, &y]() { ++x; ++y; };       // C++20

Best Practices

Use const auto& in range-for over containers. auto [k, v] copies each element; for a map<string, ExpensiveType>, that's a string copy and a deep copy per iteration.

cpp
for (const auto& [key, val] : myMap) { /* correct */ }
for (auto [key, val] : myMap)        { /* copies each pair — rarely intended */ }

Use auto& only when you intend to mutate. The ref-qualifier applies to the anonymous entity, so all bindings become references together — there is no mechanism to make only one binding a reference.

Name bindings to match the domain. The payoff of structured bindings is readability. Names like [it, inserted], [lo, hi], [begin, end] communicate intent. Names like [a, b] do not.

Prefer member access for large aggregates when you need only a few fields. Decomposing a ten-member struct to use two of them hides the type and its structure unnecessarily.

Common Pitfalls

Copy vs. reference confusion

cpp
std::pair<int, int> p{10, 20};

auto [a, b] = p;   // a and b are in an anonymous COPY of p
a = 99;            // modifies the copy — p.first is still 10

auto& [c, d] = p;  // c and d refer into p directly
c = 99;            // p.first is now 99

Dangling reference from a temporary

cpp
auto& [x, y] = std::pair{1, 2};   // UB: temporary destroyed at end of full expression

// Safe alternatives:
auto        [x, y] = std::pair{1, 2};  // copy — always safe
const auto& [x, y] = std::pair{1, 2};  // extends temporary lifetime to binding scope

const auto& extends the lifetime of the temporary to the scope of the structured binding declaration — the same rule as binding a const lvalue reference to a temporary directly.

Wrong binding count

cpp
auto [a, b]    = std::tuple{1, 2, 3};  // error: 3 elements, 2 bindings
auto [a, b, c] = std::pair{1, 2};      // error: 2 elements, 3 bindings

The count must match exactly. There are no defaults, variadics, or wildcard bindings until C++26's placeholder _.

Bit-field bindings are copies, not references

In C++20+ structured bindings over a struct with bit-fields, the bindings are value copies. Mutating a binding has no effect on the original struct member.

Structured bindings are not constexpr before C++26

cpp
constexpr auto [a, b] = std::pair{1, 2};  // ill-formed in C++17, 20, 23

For constexpr contexts targeting C++17–23, use std::get<0>() and std::get<1>() directly.

Custom Tuple Protocol: Deep Dive

The "tuple-like protocol" lets you opt any class into structured bindings — including types with private members — without making it an aggregate. Three customization points are required:

WhatWhere to specialize
Element countstd::tuple_size<T>
Element type at index Istd::tuple_element<I, T>
Element access at index Ifree get<I>(obj) or member get<I>()

Approach 1: free get function (ADL)

Suitable when you want to add binding support externally, or the type is in a different namespace:

cpp
#include <cstdint>
#include <type_traits>

struct Pixel { std::uint8_t r, g, b, a; };

// Step 1 — element count
template<> struct std::tuple_size<Pixel>
    : std::integral_constant<std::size_t, 4> {};

// Step 2 — element types
template<std::size_t I>
struct std::tuple_element<I, Pixel> {
    static_assert(I < 4);
    using type = std::uint8_t;
};

// Step 3 — element access (free function, found via ADL)
template<std::size_t I>
std::uint8_t get(const Pixel& p) noexcept {
    if constexpr      (I == 0) return p.r;
    else if constexpr (I == 1) return p.g;
    else if constexpr (I == 2) return p.b;
    else                       return p.a;
}

Pixel px{255, 128, 0, 255};
auto [r, g, b, a] = px;  // r=255, g=128, b=0, a=255

The compiler looks for a member get<I>() first; if absent, it falls back to ADL for a free get<I>(obj).

Approach 2: member get (deducing this, C++23)

Member get is the cleaner option when the type is under your control and has private fields to expose selectively. C++23's explicit object parameter (this T&&) makes it trivial to write a single overload that handles all value categories:

cpp
#include <string>
#include <utility>

class Person {
    std::string first_;
    std::string last_;
    int age_;   // intentionally NOT exposed via bindings

  public:
    Person(std::string f, std::string l, int a)
        : first_(std::move(f)), last_(std::move(l)), age_(a) {}

    // Deducing-this get<I> covers const/non-const/rvalue in one overload
    template<std::size_t I, class Self>
    constexpr auto&& get(this Self&& self) noexcept {
        if constexpr (I == 0) return std::forward<Self>(self).first_;
        else                  return std::forward<Self>(self).last_;
    }
};

template<> struct std::tuple_size<Person>
    : std::integral_constant<std::size_t, 2> {};

template<std::size_t I>
struct std::tuple_element<I, Person> {
    using type = std::remove_cvref_t<
        decltype(std::declval<Person>().template get<I>())>;
};

Person p{"Jane", "Doe", 30};
auto [first, last] = p;  // age is not exposed

Accessible-members relaxation (C++20, P0969)

In C++17, aggregate-mode structured bindings required all bound members to be public. P0969 (adopted as a DR against C++17, effective in C++20) relaxed this: if the current scope can access the member — through friendship, class-internal declarations, or inheritance — structured bindings can reach it too. Tuple-protocol bindings via get always worked because the function controls access.

C++26: _ as a Placeholder Name (P2169R4)

Before C++26, _ was an ordinary identifier. Reusing it twice in the same scope was a redefinition error, so ignoring multiple elements in nested structured bindings required creativity:

cpp
// C++17–23: redefinition error — cannot reuse _ twice in the same scope
for (const auto& [_, v] : myMap) {
    const auto& [e, r, _] = v;  // error: redefinition of '_'
}

C++26 (P2169R4) makes _ a special placeholder name. Any variable, lambda capture, or structured binding element named _ automatically receives an implicit [[maybe_unused]] attribute and may be declared any number of times in the same scope. Mentioning _ in a value expression is ill-formed — it is write-only.

cpp
// C++26: _ can appear as many times as needed
for (const auto& [_, v] : myMap) {   // C++26 — first _ is fine
    const auto& [e, r, _] = v;        // C++26 — second _ is also fine
    std::println("{}", e);
}

// Discarding unwanted fields from a tuple
auto [x, _, z] = std::tuple{1, 2, 3};  // C++26: middle element discarded
std::println("{} {}", x, z);

// Side-effect-only objects (e.g., locks) no longer need a name
auto _ = std::scoped_lock{mu};   // C++26: no "unused variable" warning
auto _ = std::scoped_lock{mu2};  // second _ in same scope: also fine

_ remains a normal identifier when it names a namespace-scope variable (to avoid breaking GMock and similar libraries that use _ as a wildcard). The placeholder semantics only apply to local variables, structured binding elements, and lambda captures.

Compiler support: GCC 14+, Clang 18+.

C++26: constexpr Structured Bindings (P2686R5)

Before C++26, structured bindings could not be declared constexpr, which blocked their use in compile-time computation:

cpp
// C++17–23: ill-formed
constexpr auto [a, b] = std::pair{1, 2};  // error before C++26

C++26 (P2686R5) lifts this restriction by introducing the concept of symbolic addressing: a constexpr reference no longer requires a globally constant absolute address; it only requires a constant address relative to the enclosing stack frame. This unblocks both constexpr references to local constexpr variables and constexpr structured bindings over tuple-like types:

cpp
// C++26
int main() {
    constexpr auto [a, b] = std::pair{2, 3};
    static_assert(a * b == 6);  // OK in C++26

    constexpr auto [x, y, z] = std::array{10, 20, 30};
    static_assert(x + y + z == 60);  // OK in C++26

    // Bonus: constexpr reference to local constexpr variable also works
    constexpr int n = 42;
    constexpr const int& r = n;  // was ill-formed before C++26
    static_assert(r == 42);
}

For constexpr-aware code targeting C++17–23, the workaround remains std::get<I>() directly, or adding static to the bound object to give it a stable address.

Note: as of late 2025 no shipping compiler has implemented P2686R5; expect support in GCC 15+ and Clang 19+.

C++26: Additional Upgrades

Per-element attributes (P0609R3)

C++26 allows attributes directly on individual binding names, not just on the declaration as a whole:

cpp
// Before C++26: attribute applies to the whole anonymous entity
[[maybe_unused]] auto [it, inserted] = map.try_emplace(key, val);

// C++26: attribute applies to a specific binding only
auto [it, inserted [[maybe_unused]] ] = map.try_emplace(key, val);

Structured binding as a condition (P0963R3)

C++26 allows a structured binding to appear directly as the condition of if, while, or for. The boolean test is applied to the anonymous entity before decomposition:

cpp
// C++26
while (auto [header, body] = receive_packet()) {
    process(header, body);
}

if (auto [ptr, ec] = std::to_chars(buf, buf + N, value)) {
    // conversion succeeded — use ptr
}

Binding packs in templates (P1061R10)

C++26 allows a structured binding to introduce a parameter pack inside a template, enabling concise generic code:

cpp
// C++26 — only valid inside a template context
template<class P, class Q>
constexpr auto dot_product(const P& p, const Q& q) {
    const auto& [...ps] = p;  // pack of all elements of p
    const auto& [...qs] = q;  // pack of all elements of q
    static_assert(sizeof...(ps) == sizeof...(qs));
    return ((ps * qs) + ...);  // fold expression
}

constexpr std::array<int, 3> a{1, 2, 3};
constexpr std::array<int, 3> b{4, 5, 6};
static_assert(dot_product(a, b) == 32);  // 1*4 + 2*5 + 3*6

Pack structured bindings are limited to template contexts in C++26; non-template pack bindings are expected in a later standard.

See Also

  • auto — the type deduction rules that underpin structured bindings
  • std::tuple — primary use case for the tuple-like protocol
  • std::pair — simplest tuple-like type; returned by insert/emplace
  • std::tie — C++11 predecessor for unpacking into pre-existing variables