In std::vector<int> v = {10, 20, 30};, the curly braces aren't a special vector feature. They construct a temporary std::initializer_list<int>, and the vector has a constructor that knows how to consume one. This chapter is about what that type actually is, how the compiler hands it to constructors and functions, and how to make a class accept the same brace-list syntax. The next chapter (uniform initialization) covers the broader brace-init story; this one stays focused on std::initializer_list itself.
std::initializer_list Actually Isstd::initializer_list<T> (since C++11, in <initializer_list>) is a small library type that represents a temporary, read-only array of T. It has three useful members: size(), begin(), and end(). The compiler constructs one whenever it sees a braced list in a context that expects one.
Three calls, three different sizes, one function. The braced list {29.99, 14.99, 9.99} is the literal, std::initializer_list<double> is the type the compiler builds to hold it, and the function reads it like any container with begin/end. No heap allocation, no template machinery on the calling side.
The instance itself is small. On a 64-bit system it's typically two pointers (or one pointer plus a size), so 16 bytes. Passing it by value has no meaningful copy cost. The elements it points at live in a separate compiler-managed array, not inside the initializer_list object.
The diagram shows what the compiler actually builds. The braced list on the left is not stored anywhere; it's a syntactic placeholder. The compiler emits a const-qualified array of three doubles (the backing array, orange) and an std::initializer_list<double> object (green) whose begin points at the first element of that array and whose end points one past the last. The function receives the green object by value; the orange array lives somewhere the compiler decides.
The split between the view object and the backing array drives every rule that follows. In void f(std::initializer_list<int> il), the il received is the small view. The array of ints lives somewhere else, usually in read-only static storage or on the caller's stack frame, depending on what the compiler decided. Copying il is cheap, and every copy is two pointers pointing at the same shared array. The array itself is never copied implicitly. That makes initializer_list cheap to pass around, and it also explains every lifetime caveat covered later: copying the view doesn't extend the array's life.
A few properties drop out of this design that matter:
| Property | Detail |
|---|---|
| Element type | const T, always. Elements can't be modified through the list. |
| Storage | Compiler-managed backing array; the list doesn't own it. |
| Copying the list | Cheap. Two pointers. The backing array is shared, not duplicated. |
| Iterators | begin() and end() return const T*. Random access is not exposed. |
| Size | size() is O(1) and returns size_t. |
The interface is intentionally minimal: begin, end, and size, which is enough for a range-based for loop, the standard algorithms, or a manual pointer walk.
std::accumulate (in <numeric>) works because initializer_list exposes the iterator interface algorithms expect. Range-based for works for the same reason: the compiler looks up begin(list) and end(list), which both exist. Anything that works against a const T* pair works against an initializer_list<T>.
Mutating the elements isn't allowed. The element type is const T, so the following won't compile:
g++ says: error: binding reference of type 'double&' to 'const double' discards qualifiers. The fix is to take the value by const double& (or by value), but then writing back to the original list isn't possible. That's by design. The list is a view of a read-only literal; for modification, copy into a std::vector first.
Copying the elements out into a std::vector<T> is O(n) and allocates. For a read-once use case, iterate the initializer_list directly. To keep the data around after the function returns, copy it; the backing array goes away.
An initializer_list can also be passed from one function to another. It's a small value type.
reportMax receives the list and hands it to maxPrice unchanged. No copy of the backing array happens; both functions look at the same elements through their own small initializer_list views.
One thing the type does not provide: random-access subscripting. There is no list[2]. For indexed access, advance an iterator manually with *(list.begin() + 2). The iterators are const T*, so pointer arithmetic works.
This is where the type stops being a function-parameter helper and starts changing how class construction works. In Cart c{...}, the compiler doesn't look for any constructor. It looks for an initializer_list constructor first, and if it finds one that fits, that wins.
Consider this minimal class:
Deleting line d and compiling the rest:
Same number, same class, completely different behavior. Cart a(10) uses parenthesis initialization, which goes through normal overload resolution and finds Cart(int). Cart b{10} uses brace initialization, which prefers an initializer_list constructor when one exists and at least one element converts to the list's element type. The int literal 10 converts to int, so the list constructor wins, and the result is a Cart constructed from a one-element list.
This rule is sometimes called the initializer-list preference rule. It's a single sentence in the standard: when a type is brace-initialized and the type has an initializer_list constructor that the braces can match, that constructor wins, even if a parenthesized call to a different constructor would have been a "better" overload.
The classic gotcha is std::vector<int>:
Tracing the calls: a calls vector(size_type count) and ends up with 10 default-initialized ints (each zero). b calls vector(std::initializer_list<int>) with a one-element list whose element is 10, so b.size() is 1 and b[0] is 10.
The last line of d reads d[0]=10, d[1]=5. Two different vectors come out of (10, 5) versus {10, 5}. This is the most concrete consequence of the initializer-list preference rule, and it's why C++ code is careful with braces around vector and any other type with mixed numeric constructors.
The preference applies only when the list is non-empty and at least one element converts to the list's element type without a narrowing conversion. The empty braces {} behave specially: they call the default constructor if one exists, falling back to a zero-element initializer_list constructor if not.
The standard works this way because brace initialization was introduced in C++11 partly to give containers a literal syntax that matches their elements. std::vector<int> v = {1, 2, 3} should mean "a vector of three ints", and the only way to make that work universally is to give initializer_list constructors first pick whenever they can plausibly bind. The trade-off is the surprise with mixed-numeric overloads like vector(size_type), but the committee considered that worth the price for the clarity of the common case.
Usually, initializer_list constructors aren't written to enable brace-syntax for callers; they're written to accept a literal batch of values. The Cart example below holds a list of product ids and lets a caller build one in a single expression.
A few details. The constructor body is empty; all the work happens in the member initializer list, where productIds_(ids) copies the elements out of the initializer_list into the vector. std::vector has its own initializer_list constructor, so this is a forward, not a hand-written loop.
addMany shows the second common pattern: a regular method that takes an initializer_list so callers can append a batch with brace syntax. The body uses vector::insert with the list's iterators, which is the cleanest way to append a range.
For an existing Cart, writing cart = {1, 2, 3} doesn't append; it calls the initializer_list constructor to build a new Cart and then assigns over the old one, throwing away whatever was there. That's another consequence of the same preference rule, this time on assignment. To append, call addMany explicitly.
Constructing a Cart from {101, 202, 303} copies three ints into the vector's heap buffer. That's O(n) and one allocation. For move-only types, this approach falls apart: the initializer_list exposes const T&, which can't be moved out of. That pitfall is covered further down.
A more realistic e-commerce example mixes the initializer_list constructor with other constructors and shows the preference rule in practice:
Cart a("priya@example.com") uses parens, so it skips the list-preference rule entirely and matches Cart(std::string). Cart b{101, 202, 303} uses braces, and the elements are ints, which match std::initializer_list<int> cleanly, so the list constructor wins. Cart c{"akira@example.com", {404, 505}} is interesting: the outer braces don't all-convert-to-int, so the list constructor is out, and overload resolution falls through to Cart(std::string, std::initializer_list<int>). That's the normal overload-resolution fallback when the list-preference rule doesn't fire.
A common pitfall: when a class has both T(initializer_list<int>) and T(int, int), brace-init always prefers the list. To call the two-int constructor with T{1, 2}, the list constructor would have to be removed or renamed, or use parens (T(1, 2)).
The examples so far pass an initializer_list to a function or constructor, and the function uses it before returning. That's the safe pattern, and it's the pattern the language is designed around. Trouble starts when an initializer_list is stored and used later.
The backing array that holds the elements has the same lifetime as the initializer_list object that was originally created from the braced list. As soon as that object goes out of scope, the backing array is gone. A saved copy of the initializer_list still points at memory that no longer exists.
Broken code:
The constructor parameter ids is a local initializer_list whose backing array's lifetime is tied to the full-expression Cart c{101, 202, 303}. By the time c.print() runs, that full-expression is finished, the temporary array is destroyed, and ids_ is a view onto freed memory. On some compilers and at low optimization levels the program will appear to work because the freed memory hasn't been overwritten yet. That's still undefined behavior.
Fix: copy the elements out into a real container that owns them.
std::vector<int> has an initializer_list constructor that copies the elements into a heap buffer it owns. Once the vector is constructed, the original backing array can vanish; the vector doesn't care.
The rule: use `std::initializer_list` as a transient handle, not as storage. Read from it in the function that received it, copy what's needed into something owned, and let it go.
Treat initializer_list like std::string_view or a raw pointer-pair. It refers to data it doesn't own. The data is fine as long as it's consumed before the owning expression ends, and it's dangerous the moment something tries to outlive that expression. The difference is that string_view has the same name everywhere, while initializer_list is mostly invisible (it shows up only in parameter types and a handful of auto cases), so the non-owning nature is easy to miss.
There's one related case where lifetime extension does kick in, and it's narrow. Directly binding an initializer_list to a local variable extends the lifetime of the backing array to match that local:
That works because the array lifetime is bound to the local ids. Passing ids to another function and storing the copy somewhere loses that binding. The safe model: "treat initializer_list like a string_view: it doesn't own its data".
The diagram traces the lifetime. The braced list at the call site creates a temporary backing array (orange) that the constructor's parameter (green) views. If the constructor copies the elements into a member std::vector (teal), the data is safely owned and the temporary can disappear. If instead the constructor stored the initializer_list itself, the red box is what the next access reads: an array that no longer exists.
std::initializer_list works directly in places that take a range, which is sometimes cleaner than building a temporary vector. The most useful case is iterating an inline list right where it's written.
The braced list {101, 202, 303, 404, 505} becomes a std::initializer_list<int>, and the range-based for loop iterates it like any other range. No vector, no allocation, no manual indexing. This is the most common everyday use of the type outside of class construction.
The same trick works with standard algorithms operating on an inline batch:
std::max has an overload that takes an initializer_list, which is how std::max({a, b, c, d}) works. Without that overload, the binary std::max(a, b) would be the only choice and calls would have to be nested.
Helper functions that accept brace lists avoid the vector tax:
Each call constructs a temporary initializer_list<std::string> with three strings. No std::vector is built; the strings live in a compiler-managed backing array for the duration of the call. That's the use case initializer_list was added for.
Each call still constructs the elements. Building {"SUMMER10", "WINTER20", "FREESHIP"} allocates three std::string objects (each potentially allocating its own storage, depending on small-string optimization). The list itself is two pointers, but the elements aren't free. For hot paths with a fixed set, declare a static const array of strings once.
The distinction between "the list is cheap" and "the elements aren't" matters most when the element type is a heavy class. initializer_list<std::string> does not intern its strings; each braced literal builds its own std::string from a string literal, and the cost of that construction is real every call. For int or double, the cost is invisible because the backing array is a few words of read-only data. For strings or other non-trivial types, the call site does real work even though the syntax looks free.
Most initializer_list bugs come from the preference rule colliding with other constructors. The earlier std::vector<int>{10} versus std::vector<int>(10) example is the canonical case. A few more crop up in real code.
Brace initialization rejects narrowing conversions (since C++11), and that rule applies inside initializer_list literals too.
Broken code:
g++ says: error: narrowing conversion of 'd' from 'double' to 'int' inside { }. The brace-init form requires every element to convert without losing information. d could be 99.99, which can't be represented exactly as int, so the compiler rejects the call rather than truncating.
Fix: cast explicitly when truncation is intended.
Now the truncation is explicit.
Empty braces have a special rule: they call the default constructor if one exists, even when an initializer_list constructor is present.
If the class had no default constructor, Cart a{} would call the initializer_list constructor with an empty list. With a default constructor present, the default wins.
The element type inside an initializer_list<T> is const T, so elements can't be moved out. That makes initializer_list<std::unique_ptr<T>> essentially useless: iteration works, but taking ownership of the pointers doesn't, which defeats the purpose.
Broken code:
The loop binds item to const std::unique_ptr<int>&, and std::move on a const value returns a const&& that can't be moved from. The vector's push_back then refuses the bind because unique_ptr is move-only.
Fix: don't use initializer_list for move-only types. Take a std::vector<std::unique_ptr<T>>&& parameter, or accept the items one at a time. Variadic templates are another option for brace-list syntax at the call site.
This limitation isn't a bug in initializer_list; it's a direct consequence of the design. The backing array is shared between every copy of the list, so allowing one consumer to move out of it would break every other consumer's view. Const elements are the language's way of enforcing "look but don't take". For value types that copy cheaply (ints, doubles, small structs, even most strings), that's the right behavior. For ownership-transfer types, a different mechanism is needed.
auto deduces an initializer_listA subtle quirk: auto with brace initialization may deduce an initializer_list rather than the expected type.
The C++17 rules tightened this: auto x{single}; deduces the element type, but auto x = {list}; still deduces initializer_list. The full rules are in the uniform-initialization chapter (the next one); the relevant point here is that this is one place initializer_list shows up unexpectedly.
std::initializer_list is a small, focused feature. It solves one problem: letting a function or constructor accept a literal batch of values at the call site without forcing the caller to build a container first. Use it for that, and nothing else.
| Use it when | Avoid it when |
|---|---|
| A function or constructor accepts a fixed batch of read-only values. | The elements need to be mutated. |
Callers will write the elements inline most often (f({1, 2, 3})). | The elements are move-only (unique_ptr, thread, etc.). |
| The elements are cheap to copy or will be copied into owned storage anyway. | The list needs to be stored for later use. |
| Brace-list construction of a class is the goal. | The element count is large enough that a std::vector parameter is more honest. |
The pattern that comes up over and over: take std::initializer_list<T> by value as a parameter, iterate it with a range-based for or an algorithm, and either consume it on the spot or copy the elements into an owning container in the constructor's member initializer list. That's 95% of correct uses.
The pattern that comes up over and over in bug reports: store the initializer_list as a member or in a static variable, then use it later. Don't do that. Copy into a std::vector and store the vector.
When designing a class with multiple constructors, decide up front whether it should behave like a container (where brace-list construction means "build with these elements") or like a regular value type (where braces initialize the named fields). For the former, give it an initializer_list constructor and accept that brace-init at the call site will always prefer that constructor. For the latter, leave the initializer_list constructor off and let brace-init route through ordinary overload resolution. Trying to have it both ways produces the most confusing call sites.
10 quizzes