July 23, 2026
A Vector Without the Heap: Is std::inplace_vector Right for Embedded C++?
std::inplace_vector gives C++26 a standard vector-like container whose maximum capacity is fixed at compile time and whose elements live inside the container itself.
That makes it immediately attractive for embedded systems. There is no heap allocation, no capacity growth and no relocation to a newly allocated buffer. The maximum storage cost is visible in the type:
#include <inplace_vector>
std::inplace_vector<Sample, 32> samples;
The container can hold between zero and 32 live Sample objects. Its capacity never changes, but its size does.
This sounds like the ideal embedded container: vector semantics without dynamic allocation. But the interesting engineering questions begin after that first sentence.
- What happens when the capacity is exhausted?
- How many objects are actually alive?
- Where should a large
inplace_vectorbe stored? - How should its capacity be selected?
- Is it a replacement for a ring buffer?
- Does fixed-capacity storage really make execution deterministic?
Note
std::inplace_vector is a C++26 library component declared in <inplace_vector>. Compiler and standard-library support is still evolving, so treat the examples as C++26-oriented code and check the exact implementation used by your target toolchain.

The missing container between array and vector
Embedded C++ code often needs a collection with three properties:
- The maximum number of elements is known.
- The actual number of elements changes at runtime.
- Dynamic allocation is forbidden, undesirable or too unpredictable.
std::array solves the storage problem, but its size is always exactly N. All N elements are part of the array object.
std::vector solves the variable-size problem, but normally owns a separate dynamically allocated buffer whose capacity may grow.
For years, embedded projects have filled the gap with custom fixed-capacity vectors, Boost static_vector, EASTL containers or project-specific storage wrappers.
C++26 standardizes that missing shape:
template<class T, std::size_t N>
class inplace_vector;
The second template argument is not an initial capacity request. It is a hard maximum and part of the type.
std::inplace_vector<Packet, 8> smallBatch;
std::inplace_vector<Packet, 32> largeBatch;
// Different types:
static_assert(
!std::same_as<decltype(smallBatch), decltype(largeBatch)>);
This matters for APIs, object layout, copying, ABI boundaries and capacity selection. Capacity is no longer hidden runtime state. It becomes part of the design.

Basic use looks deliberately familiar
The API resembles std::vector because the intended programming model is familiar:
#include <inplace_vector>
struct Reading
{
int sensorId;
float value;
};
void collectReadings()
{
std::inplace_vector<Reading, 8> readings;
readings.emplace_back(Reading{1, 21.5F});
readings.push_back(Reading{2, 22.1F});
for (const Reading& reading : readings) {
process(reading);
}
readings.pop_back();
readings.clear();
}
The container provides contiguous storage, iterators, element access, insertion, erasure, size(), capacity() and much of the normal sequence-container interface.
Two properties are especially important:
capacity()is alwaysN.- The addresses of existing elements never change because of buffer reallocation. There is no second buffer to allocate and move into.
That does not mean every modifying operation preserves every iterator or reference. Inserting or erasing in the middle still moves elements, and moving or swapping the container has different semantics from moving or swapping a normal std::vector.
Practical mental model
Think of std::inplace_vector<T, N> as a vector whose storage is permanently attached to the object, not as an array with a convenient size counter.
Capacity and size are different contracts
A default-constructed inplace_vector is empty:
std::inplace_vector<Message, 16> messages;
assert(messages.empty());
assert(messages.size() == 0);
assert(messages.capacity() == 16);
The object already contains enough storage for at most 16 Message objects, but none of those objects exists yet.
Each successful insertion starts the lifetime of one new element. Each pop_back, erase, resize to a smaller size or clear destroys elements.
This distinction is one of the biggest advantages over a plain std::array when T is expensive, non-default-constructible or owns a resource.
class Session
{
public:
Session(Socket socket, DeviceId device);
~Session();
Session(const Session&) = delete;
Session& operator=(const Session&) = delete;
Session(Session&&) noexcept = default;
Session& operator=(Session&&) noexcept = default;
};
std::inplace_vector<Session, 4> sessions;
// No Session objects have been constructed yet.
sessions.emplace_back(openSocket(), DeviceId{7});
A std::array<Session, 4> would require four Session objects to exist as part of the array. That is often the wrong lifetime model.

Overflow policy is part of the API design
A fixed-capacity container eventually has to answer one unavoidable question: what happens when it is full?
std::inplace_vector offers three useful styles for adding one element at the back.
1. Throwing insertion
std::inplace_vector<Event, 8> events;
events.push_back(event);
events.emplace_back(EventType::Connected, timestamp);
push_back and emplace_back return a reference to the inserted element. If the insertion would exceed capacity, the operation throws std::bad_alloc.
The exception name may look surprising because no allocation is attempted. It represents failure to provide the requested storage capacity, not necessarily a failed heap call.
2. Checked non-throwing insertion
auto inserted = events.try_push_back(event);
if (!inserted) {
reportEventOverflow();
return;
}
inserted->markQueued();
try_push_back and try_emplace_back report a full container through the return value. If the container is already full, they have no effect. For an rvalue argument, the input is not consumed when capacity is unavailable.
This is often the most honest choice in embedded code when fullness can occur during correct operation.
3. Unchecked insertion
if (events.size() < events.capacity()) {
events.unchecked_push_back(event);
}
unchecked_push_back and unchecked_emplace_back require available capacity as a precondition. Calling them while the container is full violates that precondition.
The unchecked form can remove the capacity branch from the operation itself, but it does not remove the engineering responsibility. The proof simply moves to the caller.
Important
Do not choose an overflow policy only from performance concerns. Decide what “full” means in the domain: a programming error, normal backpressure, data loss, a trigger to flush, or a safety-relevant fault.

A full container may be normal, not exceptional
Consider a telemetry batch that is sent whenever it reaches 16 samples. Full capacity is expected behavior:
class TelemetryBatch
{
public:
static constexpr std::size_t MaxSamples = 16;
void add(Sample sample)
{
if (!samples_.try_push_back(std::move(sample))) {
flush();
const auto inserted =
samples_.try_push_back(std::move(sample));
if (!inserted) {
reportInternalFailure();
}
}
if (samples_.size() == samples_.capacity()) {
flush();
}
}
private:
void flush();
std::inplace_vector<Sample, MaxSamples> samples_;
};
The capacity is part of the batching protocol. Reaching it is not an allocation failure and not necessarily a bug.
Another subsystem may require a different policy:
- A diagnostic snapshot may reject the newest entry.
- A safety monitor may raise a fault.
- A UI event list may drop low-priority events.
- A network sender may apply backpressure.
- A history buffer may need to overwrite the oldest entry—in which case a ring buffer is probably a better abstraction.
The container provides storage mechanics. The surrounding type should provide the domain policy.
std::inplace_vector versus std::array
| Question | std::array<T, N> | std::inplace_vector<T, N> |
|---|---|---|
| Is the capacity fixed? | Yes | Yes |
| Is the logical size variable? | No, always N | Yes, from 0 to N |
| Are all element objects alive? | Yes | Only the first size() elements |
Must T be default-constructible for default construction? | Usually relevant because all elements exist | No elements are constructed initially |
| Can elements be appended? | Not as a container operation | Yes |
| Does it track logical size? | No | Yes |
| Contiguous storage? | Yes | Yes |
| Dynamic allocation? | No | No |
A common pre-C++26 workaround is an array plus a size field:
struct Samples
{
std::array<Sample, 32> storage;
std::size_t size{};
};
This can be completely valid for simple types, but the type now has to implement its own invariants:
sizemust never exceed 32.- Algorithms must use only the active prefix.
- All 32
Sampleobjects may be constructed even if only two are used. - Erasure, insertion and exception safety are project code.
Use std::array when the collection really contains exactly N elements. Use std::inplace_vector when N is a maximum.
std::inplace_vector versus std::vector
| Question | std::vector<T> | std::inplace_vector<T, N> |
|---|---|---|
| Where are elements stored? | Normally in a separately allocated buffer | Inside the container object |
| Can capacity grow? | Yes | No |
| Maximum size known from the type? | No | Yes |
| Can insertion allocate? | Yes | No |
| Can growth relocate every element? | Yes | No buffer reallocation |
| Move construction usually cheap? | Often O(1) by taking ownership of the buffer | May move or copy elements; capacity is embedded |
| Object itself small? | Usually a few pointers | Contains storage for N elements |
| Suitable when no heap is available? | Not without a different storage strategy | Yes |
std::vector::reserve can remove reallocations up to a chosen capacity:
std::vector<Packet> packets;
packets.reserve(32);
But this is not the same model.
- The initial reserve normally allocates.
- The storage is separate from the vector object.
- Code can still insert a 33rd element and trigger growth.
- The maximum capacity is not part of the type or API contract.
A polymorphic allocator or custom allocator can provide bounded storage for std::vector, but allocator-aware vector semantics remain different. In particular, the allocator must still define what happens when its storage is exhausted, and the vector API still conceptually supports growth.
Rule of thumb
Use std::vector when the required maximum is unknown or genuinely dynamic. Use std::inplace_vector when a hard compile-time upper bound is a real property of the system.

std::inplace_vector versus a custom fixed-capacity vector
Many embedded codebases already have a type similar to this:
template<class T, std::size_t Capacity>
class FixedVector
{
public:
bool pushBack(T value);
void clear() noexcept;
std::size_t size() const noexcept;
private:
// Raw storage, lifetime management and bookkeeping.
};
A custom type may still be justified when the project needs semantics that the standard type does not provide:
- A project-wide no-exception API.
- A specific status-code or
std::expectedresult. - Compile-time restrictions on element types.
- Special memory placement or linker-section requirements.
- Instrumentation, diagnostics or safety-certification evidence.
- A deliberate overwrite-oldest policy.
But a custom container also owns difficult code:
- Alignment and raw storage.
- Starting and ending object lifetimes correctly.
- Copy and move behavior.
- Exception safety, even if exceptions are disabled in the final target.
- Iterator correctness and invalidation rules.
- Support for non-trivial and move-only element types.
- Testing across compilers, optimization levels and sanitizers.
std::inplace_vector is valuable not because a fixed-capacity vector is impossible to write. It is valuable because the standard library can provide one shared vocabulary type with specified semantics.
Migration warning
Do not replace a custom container only because the names look similar. Compare overflow behavior, exception policy, iterator guarantees, move semantics, object layout and supported element types first.
std::span is not a competing container
std::span does not own storage. It is a view over an existing contiguous range.
void transmit(std::span<const Packet> packets);
std::inplace_vector<Packet, 16> batch;
batch.try_emplace_back(makePacket());
transmit(batch);
This is a useful combination:
std::inplace_vectorowns the elements and controls their lifetimes.std::spanexposes a non-owning API that does not care which contiguous container owns them.
An API that only reads or modifies an existing sequence usually should not require a specific capacity:
// More reusable:
void normalize(std::span<Reading> readings);
// More restrictive:
void normalize(std::inplace_vector<Reading, 32>& readings);
The second function unnecessarily requires one exact owner type and one exact capacity.
Design point
Use std::inplace_vector at ownership and mutation boundaries. Use std::span at APIs that only need contiguous access to existing elements.

std::inplace_vector is not a ring buffer
A ring buffer and an inplace_vector can both use fixed-capacity storage, but they solve different problems.
| Question | std::inplace_vector | Ring buffer |
|---|---|---|
| Primary model | Variable-size contiguous sequence | Bounded queue or rolling history |
| Physical layout of logical order | Always one contiguous range | May wrap into two physical ranges |
| Push at back | O(1) until full | Usually O(1) |
| Pop at front | O(size) because elements shift | Usually O(1) |
| Insert or erase in middle | Supported, usually linear | Usually not the intended operation |
| Full policy | Reject, throw or caller precondition | Often reject or overwrite oldest |
| Best use | Small batch, bounded list, temporary work set | FIFO, streaming data, producer-consumer queue, recent-history window |
This is an inefficient queue:
std::inplace_vector<Frame, 64> queue;
Frame popFront()
{
Frame frame = std::move(queue.front());
queue.erase(queue.begin()); // shifts every remaining frame
return frame;
}
It has bounded memory, but repeated front erasure performs linear movement.
A ring buffer keeps head and tail indexes and normally removes the oldest element without shifting the rest.
Common mistake
Do not choose std::inplace_vector merely because both containers have fixed capacity. Choose based on access pattern and overflow semantics.

Fixed-capacity storage improves predictability—but not everything is deterministic
Removing dynamic allocation eliminates several important sources of variability:
- No allocator search or heap lock during insertion.
- No capacity-growth policy.
- No reallocation into a larger buffer.
- No element relocation caused by reallocation.
- A compile-time upper bound on element count.
These are strong properties for embedded, low-latency and safety-oriented systems.
But “no allocation” is not the same as “constant-time” and not the same as a verified worst-case execution time.
Execution still depends on the operation and on T:
| Operation | Typical work | Potential variability |
|---|---|---|
push_back | Constant container bookkeeping plus construction of one T | T constructor, overflow path, exception machinery |
pop_back | Destroy one element | T destructor |
| Insert in middle | Move elements after insertion point | Linear in distance to end |
| Erase in middle | Move-assign later elements and destroy trailing element | Linear in distance to end |
clear | Destroy all live elements | Linear in current size |
| Copy or move container | May process elements or the embedded object representation | Depends on T, size, capacity and implementation |
swap | Exchange embedded contents | Linear rather than pointer swap |
Hardware effects also remain:
- Cache misses and memory hierarchy.
- Interrupts and task preemption.
- Branch prediction.
- Lock contention outside the container.
- Compiler and standard-library implementation choices.
- User-defined constructors, destructors, moves and comparisons.
Better claim
std::inplace_vector provides bounded storage and removes allocation-driven growth. It can make timing easier to bound, but the complete operation still needs worst-case analysis.

The element type can reintroduce allocation
The container itself does not dynamically allocate. That guarantee does not recursively apply to the objects stored inside it.
struct Record
{
std::string name;
std::vector<std::byte> payload;
};
std::inplace_vector<Record, 8> records;
Appending a Record can still allocate through std::string or the nested std::vector.
If the system requires an allocation-free path, the complete object graph must satisfy that requirement:
struct Record
{
std::array<char, 24> name;
std::uint8_t nameLength{};
std::array<std::byte, 128> payload;
std::uint16_t payloadLength{};
};
std::inplace_vector<Record, 8> records;
This version has a larger fixed footprint, but its nested storage is bounded as well.
Warning
“The container does not allocate” is not the same as “this code path does not allocate.” Audit constructors, copies, moves, callbacks and nested members too.
Capacity selection is a system requirement, not a guess
The most important template argument is also the easiest one to choose badly.
std::inplace_vector<Frame, 1024> frames;
A very large number appears safe because overflow becomes unlikely. But every instance now carries storage for 1024 frames whether it contains 1024, 2 or 0 live elements.
A useful capacity should come from a real upper bound:
- Maximum entries in a protocol message.
- Maximum descriptors supported by the hardware block.
- Maximum sensors configured for the product variant.
- Maximum events processed in one scheduler cycle.
- Maximum batch permitted by latency or transport constraints.
- A measured burst bound plus a documented safety margin.
The reasoning should be visible in code:
class CanRxBatch
{
public:
// The controller exposes 32 receive descriptors.
static constexpr std::size_t MaxFrames = 32;
private:
std::inplace_vector<CanFrame, MaxFrames> frames_;
};
If there is no credible upper bound, std::inplace_vector may be the wrong container. Hiding uncertainty behind an enormous compile-time capacity trades heap risk for static memory waste, stack risk and expensive copies.
Do the memory arithmetic
The object contains storage for its full capacity plus size bookkeeping and possible padding.
A rough lower-bound estimate is:
capacity * sizeof(T) + bookkeeping + alignment padding
For example, if CanFrame occupies 80 bytes:
std::inplace_vector<CanFrame, 64> frames;
// Embedded element storage alone:
// 64 * 80 bytes = 5120 bytes
That may be fine in static storage and dangerous in a small task stack.
Also consider multiplication through nesting:
struct Channel
{
std::inplace_vector<CanFrame, 64> pending;
};
std::array<Channel, 8> channels;
The design reserves storage for up to 512 frames across the object graph.
Embedded warning
“No heap” often means “more stack or static memory.” Check the map file, object sizes, task stack budgets and worst-case nesting instead of assuming the memory problem disappeared.

Stack, static storage or object member?
The elements are stored inside the container object, but the container object itself can live in different places.
Automatic storage
void processCycle()
{
std::inplace_vector<Task, 16> readyTasks;
// Stored in this function's stack frame.
}
This is convenient for small bounded work sets. It must fit the thread or RTOS task stack, including call depth and interrupt usage.
Static storage
namespace
{
std::inplace_vector<DiagnosticEvent, 128> diagnosticEvents;
}
This removes stack pressure but creates global lifetime, initialization-order and concurrency considerations.
A member of a long-lived object
class DeviceService
{
private:
std::inplace_vector<PendingRequest, 32> pending_;
};
This often gives the clearest ownership model, but the service object becomes correspondingly large. Passing or moving it by value may be expensive.
The container does not decide its own memory region. Normal C++ object placement does.
Move and copy may be more expensive than expected
Moving a normal std::vector can often transfer ownership of three small fields: a pointer, size and capacity. The element buffer stays where it is.
An inplace_vector cannot generally transfer ownership of an external buffer because the storage is part of the object being moved.
using Batch = std::inplace_vector<Packet, 64>;
Batch buildBatch();
Batch batch = buildBatch();
Copying or moving can involve element-wise work. For trivially copyable or trivially movable element types, the container is required to preserve trivial special-member behavior. That can allow efficient low-level copying, but a large capacity also means a large object representation may be copied even when the logical size is small.
This makes several practices important:
- Avoid passing large
inplace_vectorobjects by value without measuring. - Prefer references or
std::spanfor observation. - Inspect generated code for large trivially copyable capacities.
- Do not assume move is O(1).
- Do not put very large capacities into frequently copied message types.
Performance trap
A container can avoid heap allocation and still become expensive because its entire embedded capacity travels with every object copy, move or pass-by-value boundary.
Iterator stability is better in one way and different in others
Because the container never reallocates its storage, appending an element does not move the previously existing elements into a different allocation.
That is useful when another component temporarily holds a pointer or reference to an existing element.
std::inplace_vector<Node, 8> nodes;
Node& first = nodes.emplace_back(Node{1});
nodes.emplace_back(Node{2});
// No buffer reallocation occurred.
// The first element remains in the embedded storage.
use(first);
But normal sequence operations still matter:
- Erasing an element invalidates references and iterators at or after that point.
- Inserting in the middle shifts later elements.
- Moving the container invalidates iterators into the original object.
- Swapping two
inplace_vectorobjects exchanges embedded contents and invalidates iterators.
The absence of reallocation improves one category of stability. It does not turn every iterator into a permanent handle.
An embedded example: bounded CAN frame batching
Suppose a service reads all currently available CAN frames and then processes them as one batch.
#include <inplace_vector>
enum class BatchStatus
{
Complete,
CapacityReached,
DriverError
};
struct CanBatch
{
std::inplace_vector<CanFrame, 32> frames;
BatchStatus status{BatchStatus::Complete};
};
CanBatch readAvailableFrames(CanDriver& driver)
{
CanBatch batch;
while (true) {
auto frame = driver.tryRead();
if (!frame) {
if (frame.error() == CanError::NoData) {
return batch;
}
batch.status = BatchStatus::DriverError;
return batch;
}
if (!batch.frames.try_push_back(std::move(*frame))) {
batch.status = BatchStatus::CapacityReached;
return batch;
}
}
}
This design makes several properties visible:
- The batch holds at most 32 frames.
- Collecting the frames does not grow the container through allocation.
- A full batch is represented separately from a driver error.
- The caller can decide whether to process immediately, count overflow events or continue reading later.
The type still does not make the whole path deterministic. driver.tryRead(), CanFrame moves, error handling and later processing must also be bounded.
It is also not automatically safe between an interrupt handler and a task. std::inplace_vector provides no synchronization or lock-free queue semantics. For single-producer/single-consumer communication, a dedicated ring buffer or queue is usually the better abstraction.

Exception-disabled targets need an explicit policy
Many embedded projects compile with exceptions disabled. That does not make the throwing overloads conceptually non-throwing.
An operation such as push_back still has specified failure behavior when capacity is exceeded. How a particular compiler and library configuration handles a throw in a no-exception build is implementation-specific and may terminate the program or reject parts of the code.
For an ordinary capacity condition, prefer the API that represents it directly:
bool enqueue(Command command)
{
return pending_.try_push_back(std::move(command)).has_value();
}
Use the unchecked overload only when the capacity invariant has already been established:
void appendKnownCount(std::span<const Command> commands)
{
assert(commands.size() <= pending_.capacity() - pending_.size());
for (const Command& command : commands) {
pending_.unchecked_push_back(command);
}
}
For high-assurance code, the assertion alone may not be enough. The proof can come from an earlier checked boundary, a protocol limit, static analysis, contracts, tests or system-level safety evidence.
Warning
Do not use an unchecked operation merely to avoid exceptions. Use it only when available capacity is an established invariant.
A practical decision table
| Situation | Prefer | Why |
|---|---|---|
Exactly N objects always exist | std::array<T, N> | Fixed size is the real model. |
| Variable number of elements with a hard compile-time maximum | std::inplace_vector<T, N> | Variable lifetime and vector-like access without dynamic growth. |
| Maximum is unknown or changes with input | std::vector<T> | Runtime growth is required. |
| Function only needs contiguous access | std::span<T> | Avoid coupling the API to ownership and capacity. |
| FIFO queue with frequent front removal | Ring buffer or bounded queue | Avoid linear shifting. |
| Rolling history that overwrites oldest data | Ring buffer | Overwrite policy is part of the abstraction. |
| Project needs specialized certified semantics | Reviewed custom fixed-capacity type | The standard policy may not match project requirements. |
| Capacity exhaustion is normal | try_push_back / try_emplace_back | Failure is explicit and non-throwing. |
| Capacity is proven before insertion | unchecked_push_back carefully | Caller owns the precondition. |
Common mistakes
- Treating capacity as a guess. A hard bound should come from the system, protocol, hardware or measured burst model.
- Assuming “no allocation” means O(1). Middle insertion, erasure, clearing, moves and swaps can still be linear.
- Ignoring the element type. A stored
std::stringor nestedstd::vectorcan allocate. - Putting a large capacity on a small task stack. The full storage belongs to the object even when it is empty.
- Using it as a queue. Repeated front erasure shifts elements; use a ring buffer for FIFO behavior.
- Using throwing insertion for normal backpressure. Fullness may deserve a checked return path.
- Using unchecked insertion without a proof. Removing a branch does not remove the capacity invariant.
- Passing large containers by value. Embedded capacity can make copying and moving expensive.
- Exposing capacity in every API. Use
std::spanwhen a function only needs a view. - Assuming it is thread-safe or ISR-safe. Storage policy does not provide synchronization.
A review checklist for embedded use
- What proves the selected maximum capacity?
- What should happen when the container is full?
- Can fullness occur during correct operation?
- Does the chosen insertion API express that policy?
- Does
Tallocate, block, lock or perform unbounded work? - What is
sizeof(std::inplace_vector<T, N>)on the target? - Where will each instance live: stack, static storage or a long-lived object?
- How many instances can exist simultaneously?
- Are copy, move and swap costs acceptable?
- Does the access pattern really need a vector rather than a ring buffer?
- Can consumer APIs take
std::spaninstead of the owning type? - Has the worst-case operation—not only memory usage—been measured or analyzed?
If these questions have clear answers, std::inplace_vector can be a very strong fit. If they do not, the container may only be hiding an unresolved system requirement inside a template argument.
The key idea
If you remember only one thing, remember this:
Tip
std::inplace_vector is for a variable number of live objects with a real, compile-time maximum. It provides bounded embedded storage—not an automatic guarantee of constant-time or real-time execution.
Compared with std::array, it gives the collection a runtime size and controls element lifetime.
Compared with std::vector, it removes dynamic capacity growth and keeps storage inside the object.
Compared with a custom fixed-capacity vector, it provides standard semantics and a shared vocabulary type.
Compared with std::span, it owns storage rather than merely viewing it.
Compared with a ring buffer, it models a contiguous sequence rather than a bounded FIFO or rolling history.
The new container is useful because it makes a common embedded design explicit: the size may change, but the storage budget may not.
The template argument N is therefore more than a performance choice. It is a system contract.