Storage Duration
The four lifetime categories of object storage in C++ — automatic, static, dynamic, and thread — and what determines each.
Storage Durationsince C++98Storage duration is the property of an object that defines the minimum potential lifetime of the storage containing it, determined entirely by the construct used to create the object.
Overview
Every object in C++ belongs to exactly one of four storage duration categories. The category controls when memory is acquired, when it is released, and how the runtime initialises the object. Storage duration is independent of scope: a function-local static variable has block scope but program-length storage.
| Duration | Produced by | Lifetime |
|---|---|---|
| Automatic | Local variable without static | Enclosing block |
| Static | Namespace scope; static; extern | Program lifetime |
| Dynamic | new expression; placement-new | Explicit control |
| Thread | thread_local (C++11) | Thread lifetime |
Automatic Storage Duration
Local variables declared without static or extern have automatic storage duration. Storage is allocated when execution enters the enclosing block and released on exit. The implementation is typically a stack-pointer adjustment — effectively free compared to heap allocation.
Destructors of automatic objects run in reverse construction order, and this is guaranteed to happen even during stack unwinding caused by an exception. Automatic duration is the physical foundation of RAII.
Static Storage Duration
Objects with static storage duration persist for the entire program. Three constructs produce them:
- Variables at namespace scope (including file-scope globals)
- Local variables declared
static - Variables declared
extern
Static-duration objects are zero-initialised before any other initialisation. For constant initialisers this is followed by constant initialisation at compile time. For non-constant initialisers, dynamic initialisation occurs either before main (for namespace-scope variables) or on first execution of the declaration (for function-local statics). Since C++11, the initialisation of function-local statics is guaranteed thread-safe — the runtime serialises concurrent first-callers.
Dynamic Storage Duration
Objects created with new expressions have dynamic storage duration. Their lifetime begins at allocation and ends at the matching delete. The programmer controls the lifetime entirely, which is exactly where memory bugs originate: leaks, double-frees, and use-after-free all stem from mismanaged dynamic duration.
Since C++11, raw new/delete in application code is a code smell. Wrap dynamic objects in std::unique_ptr (C++11) or std::shared_ptr (C++11) and let ownership semantics handle the delete.
Thread Storage Duration (C++11)
The thread_local specifier creates one object instance per thread. Each instance is allocated when its thread starts and destroyed when the thread ends. Initialisation follows the same rules as static duration, scoped to the thread. thread_local can be combined with static or extern to control linkage without changing the per-thread lifetime.
Syntax
// Automatic — local variable, no specifier
void f() {
int x = 0; // automatic storage duration
std::string s; // automatic; destructor called when block exits
}
// Static — namespace scope
int request_count = 0; // static storage duration, external linkage
// Static — file scope
static int module_id = 42; // static storage duration, internal linkage
// Static — function-local (thread-safe init since C++11)
int& next_id() {
static int id = 0; // initialised once across all calls
return ++id;
}
// Static — class member (definition outside the class)
struct Config {
static int max_connections; // declaration
};
int Config::max_connections = 64; // definition, static storage duration
// Dynamic
void g() {
int* raw = new int(42); // dynamic storage duration
delete raw; // explicit release required
auto p = std::make_unique<int>(42); // C++14 — preferred
} // p's destructor calls delete; no leak
// Thread-local (C++11)
thread_local int errno_cache = 0;
// Thread-local with internal linkage (C++11)
static thread_local std::vector<char> scratch;Examples
RAII over Automatic Duration
Tying resource ownership to an automatic object's lifetime makes cleanup unconditional.
#include <fstream>
#include <mutex>
std::mutex log_mutex;
void append_log(std::string_view msg) { // std::string_view: C++17
std::lock_guard<std::mutex> lock{log_mutex}; // C++11 — automatic
std::ofstream out{"app.log", std::ios::app}; // automatic
out << msg << '\n';
} // out closed, then lock released — guaranteed, even if an exception was thrownConstruct-on-First-Use for Static Objects
class ConnectionPool {
public:
static ConnectionPool& instance() {
static ConnectionPool pool; // C++11: guaranteed thread-safe initialisation
return pool;
}
void acquire() { /* ... */ }
private:
ConnectionPool() { /* expensive one-time setup */ }
};
void worker() {
ConnectionPool::instance().acquire();
}The function-local static is zero-initialised before main, then its constructor runs on the first call. Subsequent calls skip initialisation entirely. This pattern sidesteps the static initialisation order fiasco (see Pitfalls).
Thread-Local Request Context
#include <cstdint>
struct RequestCtx {
uint64_t request_id{};
int user_id{-1};
};
thread_local RequestCtx current_ctx; // C++11 — one instance per thread
void set_request(uint64_t rid, int uid) {
current_ctx = {rid, uid};
}
uint64_t get_request_id() {
return current_ctx.request_id; // reads this thread's copy
}Each thread pool thread gets its own current_ctx. No locking required because no sharing occurs.
Unique Ownership of Dynamic Objects
#include <memory>
struct Node {
int value;
std::unique_ptr<Node> left, right; // C++11
};
std::unique_ptr<Node> build_tree(int depth) {
if (depth == 0) return nullptr;
auto node = std::make_unique<Node>(depth); // C++14
node->left = build_tree(depth - 1);
node->right = build_tree(depth - 1);
return node; // ownership transferred; caller's unique_ptr takes over
}
// When the root goes out of scope, the entire tree is destroyed recursively.constinit for Static Variables (C++20)
constinit int thread_pool_size = 8; // C++20 — enforces constant initialisation
// constinit int size = compute_size(); // error: not a constant expression
// Without constinit, this would silently become dynamic init with ordering hazards.constinit doesn't make the variable const — it mandates that initialisation happens at compile time, eliminating dynamic-init ordering issues.
Best Practices
Default to automatic duration. Stack allocation costs nothing and cannot leak. Move to dynamic duration only when object size is unknown at compile time, lifetime must outlast the current scope, or the object is too large for the stack.
Use std::unique_ptr as the default owner of dynamic objects. std::shared_ptr is appropriate only when ownership is genuinely shared across independent parties; shared ownership adds atomic reference-counting overhead and makes reasoning about lifetime harder, not easier.
Use function-local statics to control initialisation order. If a static-duration object's initialiser depends on another, wrap both in functions returning references to local statics. This defers initialisation to first use, in deterministic order.
Mark thread-local variables with non-trivial constructors carefully. In a long-lived thread pool, thread-local construction happens once per thread — fine. In a workload that spawns thousands of short-lived threads, constructor overhead per thread can accumulate.
Audit lambda captures involving automatic variables. A lambda that escapes its creation scope (stored in a std::function, posted to an executor, or returned) must not capture automatic variables by reference. The reference dangles when the enclosing block exits.
Common Pitfalls
Dangling Reference to Automatic Storage
int* get_value() {
int x = 42;
return &x; // undefined behaviour: x is destroyed on return
}
std::string_view get_view() { // C++17
std::string s = "hello";
return s; // undefined behaviour: s is destroyed, view dangles
}Compilers warn about the pointer case. The string_view case is subtler and often goes undiagnosed.
Static Initialisation Order Fiasco
Namespace-scope variables in different translation units with dynamic initialisers have unspecified relative initialisation order. Reading one from another's initialiser is undefined behaviour.
// file_a.cpp
int limit = compute_limit(); // dynamic init
// file_b.cpp
extern int limit;
int adjusted = limit + 10; // undefined: limit may not be initialised yetFix: replace global variables with function-local statics.
int get_limit() {
static int v = compute_limit(); // initialised on first call (C++11: thread-safe)
return v;
}
int get_adjusted() { return get_limit() + 10; } // safe: get_limit() called firstThread-Local Variables Are Not Captured by Lambdas
[=] and [&] capture only automatic-duration variables. Static and thread-local variables are accessed directly inside the lambda body — they are not copies.
thread_local int tval = 10;
auto f = [=]() {
return tval; // NOT a captured copy — reads current thread's tval at call time
};
tval = 99;
f(); // returns 99, not 10 — tval was not capturedThis is frequently surprising when a lambda is posted to a different thread: tval in the lambda refers to the executing thread's copy, not the creating thread's copy.
Static Storage Duration: Global and Static Variables
Initialization order within a single translation unit is top-to-bottom in declaration order. Across translation units, the relative order of dynamic initialization is unspecified — this is the root cause of the Static Initialization Order Fiasco (SIOF). Constants receive constant initialization first, before any dynamic initialization occurs.
// C++17
inline constexpr int kMaxRetries = 3; // constant init, safe across TUs
inline constexpr double kTimeoutSecs = 5.0; // header-safe: inline avoids ODR issuesA function-local static is initialized exactly once, on the first time execution reaches its declaration, regardless of how many times the function is called.
// C++11
int next_request_id() {
static int counter = 0; // zero-init before main, incremented on first call
return ++counter; // thread-safe init guaranteed since C++11
}constinit (C++20) acts as a compile-time assertion that a static-duration variable receives constant initialization, catching ordering hazards before they become runtime bugs.
// C++20
constinit int thread_pool_size = 8; // must be constant-initialized
// constinit int bad = std::time(nullptr); // error: not a constant expressionThe combination of inline constexpr (C++17) for header-defined constants and constinit for mutable globals eliminates the two most common SIOF-adjacent mistakes.
Thread Storage Duration: thread_local
thread_local gives every thread its own independent copy of a variable, with no sharing and no locking required. The storage is allocated when the thread starts and released when the thread exits; non-trivial destructors run on thread exit just as automatic-duration destructors run on scope exit.
// C++11
#include <random>
thread_local std::mt19937 rng{std::random_device{}()}; // seeded independently per threadNon-trivial types work exactly as expected: the constructor runs once per thread on first use, and the destructor runs when that thread terminates.
// C++11
#include <vector>
thread_local std::vector<char> scratch_buffer; // ctor/dtor per thread, not per callThe distinction between thread_local and static inside a function is significant: static creates one object shared by all threads, while thread_local creates one object per thread.
// C++11
void f() {
static int shared_count = 0; // one instance, shared — requires synchronization
thread_local int per_thread = 0; // one instance per thread — no synchronization needed
++shared_count; // data race without a lock
++per_thread; // safe: no sharing
}The storage cost is proportional to the number of live threads: a thread_local variable with a large type in a thread pool of 64 threads allocates 64 copies.
Dynamic Storage Duration: new/delete vs Smart Pointers
Raw new creates an object with dynamic storage duration whose lifetime ends only at an explicit delete. Any code path that exits without calling delete — including exceptions — leaks the object; this is why raw new/delete in application code is considered a code smell since C++11.
// C++11
#include <memory>
void raw_vs_smart() {
int* raw = new int(42); // dynamic duration — must remember to delete
delete raw; // explicit; skipped on exception path above = leak
auto p = std::make_unique<int>(42); // C++14: preferred; delete on scope exit
}Placement new constructs an object inside pre-allocated memory without allocating new storage; the programmer is responsible for calling the destructor explicitly before reusing or freeing the buffer.
// C++11
#include <new>
alignas(int) unsigned char buf[sizeof(int)];
int* p = new (buf) int(99); // placement new: constructs in buf, no allocation
p->~int(); // explicit destructor call required before buf reusestd::make_unique (C++14) and std::make_shared (C++11) are the modern replacements for bare new; they are exception-safe and express ownership intent directly in the type.
// C++14
auto node = std::make_unique<Node>(42); // no raw new; ownership clear from typeFor custom allocators, operator new can be overloaded globally or per-class to redirect allocations to a pool, arena, or tracking allocator without changing call sites.
Automatic Storage Duration and RAII
Local variables without static or thread_local have automatic storage duration: they are created when their enclosing block is entered and destroyed — in reverse construction order — when the block exits. Destructors run whether the block exits normally or via an exception, making automatic duration the physical foundation of RAII.
RAII exploits guaranteed destruction to tie resource lifetime to scope lifetime. A mutex lock is the canonical example:
// C++11
#include <mutex>
std::mutex m;
void critical_section() {
std::lock_guard<std::mutex> lock(m); // acquired here
// ... work ...
} // lock's destructor releases m — always, even if an exception is thrownstd::unique_ptr<T> applies the same principle to heap memory: ownership ends when the unique_ptr goes out of scope.
// C++11
#include <memory>
void use_resource() {
auto res = std::make_unique<Resource>(); // dynamic object, automatic owner
res->do_work();
} // res destroyed here → delete called on the ResourceC++23 introduces std::scope_exit (from <scope>), a general-purpose scope guard that runs any callable on scope exit, covering cases where a custom RAII wrapper is not worth writing.
// C++23
#include <scope>
void with_cleanup() {
open_handle();
std::scope_exit guard{[]{ close_handle(); }}; // runs on any exit path
do_work(); // if this throws, close_handle() still runs
}RAII is strictly superior to finally blocks found in Java and Python: it is composable (multiple resources in one function require no nesting), it works with exceptions without special syntax, and it cannot be accidentally omitted.
Static Initialization Order Fiasco (SIOF) and Fixes
The SIOF occurs when a namespace-scope object in one translation unit depends on a namespace-scope object in another TU, and C++ provides no guarantee about which is initialized first. The result is a program that reads an object in its zero-initialized (not yet constructed) state.
// file_a.cpp — dynamic initialization
struct Registry {
Registry() { /* reads global Config */ }
};
Registry global_registry; // may run before Config is initialized
// file_b.cpp
struct Config { Config() { /* ... */ } };
Config global_config; // no ordering guarantee relative to global_registryThe Construct on First Use idiom eliminates the hazard by replacing globals with function-local statics, whose initialization is deferred to first call and is thread-safe since C++11.
// C++11
Config& get_config() {
static Config instance; // initialized on first call, thread-safe
return instance;
}
Registry& get_registry() {
static Registry instance; // get_config() called inside Registry ctor: safe
return instance;
}constinit (C++20) provides a compile-time guard for the subset of statics that should have constant initialization: if the initializer is not a constant expression, the program fails to compile rather than silently falling into dynamic initialization with SIOF exposure.
// C++20
constinit int kDefaultPort = 8080; // constant init: no SIOF possible
// constinit int bad = load_from_env(); // compile error: not constexprTogether, Construct on First Use and constinit cover the two classes of static-duration objects: those that must be dynamic (use the function-local static idiom) and those that can be constant (use constinit to enforce it).
Storage Duration and Lifetime: Key Distinctions
Storage duration and object lifetime are related but not the same thing. Storage duration describes when memory is reserved; object lifetime describes when a constructor has run and before a destructor has run. Placement new and manual destructor calls can separate the two concepts.
// C++11
#include <new>
alignas(int) unsigned char storage[sizeof(int)]; // static storage duration
// storage's lifetime == program lifetime
// but no int object exists yet
int* p = new (storage) int(7); // int's lifetime begins here
p->~int(); // int's lifetime ends here; storage still validstd::optional<T> is a standard example of lifetime management within a fixed storage region: the optional's storage has whatever duration the optional itself has, but T's lifetime starts at emplace() and ends at reset().
// C++17
#include <optional>
std::optional<std::string> opt; // storage: automatic; string lifetime: not yet started
opt.emplace("hello"); // string lifetime begins
opt.reset(); // string lifetime ends; optional storage remainsstd::launder is needed when you reuse storage for a new object of the same type and must tell the compiler that a pointer obtained before placement new now refers to the new object, not the old one (important for pointer provenance and optimizer correctness).
// C++17
#include <new>
struct S { const int val; };
alignas(S) unsigned char buf[sizeof(S)];
S* p1 = new (buf) S{1};
p1->~S();
S* p2 = new (buf) S{2};
S* safe = std::launder(reinterpret_cast<S*>(buf)); // correct: laundered pointer to S{2}The table below summarizes how the four storage durations compare across the key lifecycle dimensions:
| Storage duration | Lifetime begins | Lifetime ends | Scope | Initialization |
|---|---|---|---|---|
| Automatic | Block entry | Block exit | Block | At declaration |
| Static | Program start (or first call for function-local) | Program end | Varies | Before main or first call |
| Thread | Thread start (or first use for function-local) | Thread end | Varies | Per-thread before first use |
| Dynamic | new expression | delete expression | N/A | At new expression |
See Also
reference/language/auto— type deduction interacts with automatic-duration variablesreference/language/const-correctness—constobjects with static duration and their initialisation guaranteesreference/language/constant-expressions—constexprandconstinit(C++20) for static-duration initialisation