Skip to content
C++
Language
Basic

Storage Duration

The four lifetime categories of object storage in C++ — automatic, static, dynamic, and thread — and what determines each.

Storage Durationsince C++98

Storage 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.

DurationProduced byLifetime
AutomaticLocal variable without staticEnclosing block
StaticNamespace scope; static; externProgram lifetime
Dynamicnew expression; placement-newExplicit control
Threadthread_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

cpp
// 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.

cpp
#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 thrown

Construct-on-First-Use for Static Objects

cpp
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

cpp
#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

cpp
#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)

cpp
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

cpp
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.

cpp
// 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 yet

Fix: replace global variables with function-local statics.

cpp
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 first

Thread-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.

cpp
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 captured

This 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.

cpp
// C++17
inline constexpr int kMaxRetries = 3;      // constant init, safe across TUs
inline constexpr double kTimeoutSecs = 5.0; // header-safe: inline avoids ODR issues

A function-local static is initialized exactly once, on the first time execution reaches its declaration, regardless of how many times the function is called.

cpp
// 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.

cpp
// C++20
constinit int thread_pool_size = 8;        // must be constant-initialized
// constinit int bad = std::time(nullptr);  // error: not a constant expression

The 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.

cpp
// C++11
#include <random>

thread_local std::mt19937 rng{std::random_device{}()}; // seeded independently per thread

Non-trivial types work exactly as expected: the constructor runs once per thread on first use, and the destructor runs when that thread terminates.

cpp
// C++11
#include <vector>

thread_local std::vector<char> scratch_buffer; // ctor/dtor per thread, not per call

The 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.

cpp
// 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.

cpp
// 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.

cpp
// 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 reuse

std::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.

cpp
// C++14
auto node = std::make_unique<Node>(42); // no raw new; ownership clear from type

For 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:

cpp
// 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 thrown

std::unique_ptr<T> applies the same principle to heap memory: ownership ends when the unique_ptr goes out of scope.

cpp
// 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 Resource

C++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.

cpp
// 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.

cpp
// 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_registry

The 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.

cpp
// 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.

cpp
// C++20
constinit int kDefaultPort = 8080;       // constant init: no SIOF possible
// constinit int bad = load_from_env();  // compile error: not constexpr

Together, 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.

cpp
// 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 valid

std::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().

cpp
// 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 remains

std::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).

cpp
// 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 durationLifetime beginsLifetime endsScopeInitialization
AutomaticBlock entryBlock exitBlockAt declaration
StaticProgram start (or first call for function-local)Program endVariesBefore main or first call
ThreadThread start (or first use for function-local)Thread endVariesPer-thread before first use
Dynamicnew expressiondelete expressionN/AAt new expression

See Also

  • reference/language/auto — type deduction interacts with automatic-duration variables
  • reference/language/const-correctness — const objects with static duration and their initialisation guarantees
  • reference/language/constant-expressions — constexpr and constinit (C++20) for static-duration initialisation