Skip to content
C++
Library
since C++23
Basic

std::stacktrace (C++23)

Capture and inspect runtime call stacks with std::stacktrace — frame iteration, exception integration, allocator control, and compiler setup.

std::stacktracesince C++23

A random-access sequence of std::stacktrace_entry frames captured eagerly at a specific point in program execution, exposing each frame's demangled function description, source file path, and line number.

Overview

<stacktrace> introduces two cooperating types. std::stacktrace_entry represents one frame: an implementation-defined native handle (typically an instruction pointer) plus optional debug metadata — demangled name, source file, line. std::basic_stacktrace<Allocator> is a contiguous, immutable sequence of those entries. std::stacktrace is simply an alias for std::basic_stacktrace<std::allocator<std::stacktrace_entry>>.

The call stack is walked eagerly by std::stacktrace::current(). Symbol resolution — translating addresses to names, files, and lines — is implementation-defined and may happen at capture time or lazily on first access. What is guaranteed is that the native handles remain valid for the lifetime of the stacktrace object, so you can store the object and format it later.

Both std::stacktrace and std::stacktrace_entry ship with std::formatter specializations in C++23, so they work directly with std::format and std::println. std::to_string() and operator<< are also provided. std::stacktrace satisfies std::ranges::random_access_range.

Syntax

cpp
#include <stacktrace>  // C++23

// Capture the full stack from the call site
std::stacktrace t1 = std::stacktrace::current();

// Skip the top `skip` frames (0-based; useful to omit infrastructure)
std::stacktrace t2 = std::stacktrace::current(skip);

// Skip and limit: capture at most max_depth frames
std::stacktrace t3 = std::stacktrace::current(skip, max_depth);

// All overloads accept an optional allocator as their last argument
std::stacktrace t4 = std::stacktrace::current(skip, max_depth, alloc);

std::stacktrace_entry API

cpp
std::stacktrace_entry f = trace[0];

f.description();     // → std::string: demangled function signature, or "" if unavailable
f.source_file();     // → std::string: absolute source path, or ""
f.source_line();     // → uint_least32_t: 1-based line number, or 0
f.native_handle();   // → native_handle_type (implementation-defined; typically void*)

// Evaluates false if the entry is null/invalid
if (f) { ... }

// Formatting — all equivalent in C++23
std::string s  = std::to_string(f);
std::println("{}", f);
std::cout << f << '\n';

std::stacktrace provides size(), empty(), operator[], at(), get_allocator(), full bidirectional iterators, and comparison operators (==, <=>). Elements are immutable; the container is copyable and movable.

Examples

Basic capture and print

cpp
#include <stacktrace>
#include <print>

void inner() {
    // C++23: format directly via std::formatter specialization
    std::println("{}", std::stacktrace::current());
    // Example output (with debug symbols):
    //  0: inner() at diagnostics.cpp:6
    //  1: outer() at diagnostics.cpp:14
    //  2: main at diagnostics.cpp:22
}

Embedding a trace in custom exceptions

The critical invariant: capture at the throw site, not the catch site. By the time the stack has unwound to a catch block, all intermediate frames are gone. Capturing there records only the handler.

cpp
#include <stacktrace>
#include <stdexcept>
#include <print>

class TracedException : public std::runtime_error {
    std::stacktrace trace_;
public:
    // skip=1 strips TracedException's own constructor from the trace,
    // leaving the actual throw site at index 0.
    explicit TracedException(std::string msg, std::size_t extra_skip = 0)
        : std::runtime_error{std::move(msg)}
        , trace_{std::stacktrace::current(1 + extra_skip)} {}

    const std::stacktrace& trace() const noexcept { return trace_; }
};

void validate(int value) {
    if (value < 0)
        throw TracedException{"negative value: " + std::to_string(value)};
}

int main() {
    try {
        validate(-3);
    } catch (const TracedException& e) {
        std::println(stderr, "error: {}", e.what());
        std::println(stderr, "stack:\n{}", e.trace());
        // Prints the throw site in validate(), then main() — not the catch block
    }
}

Frame iteration with source guard

When debug symbols are absent, source_file() returns an empty string and source_line() returns 0. Guard against this to avoid printing useless "(unknown):0" lines:

cpp
auto trace = std::stacktrace::current(1);  // skip the logger frame itself

for (std::size_t i = 0; i < trace.size(); ++i) {
    const auto& f = trace[i];
    if (f.source_file().empty()) {
        std::println("  #{}: {} @ {:p}", i,
            f.description().empty() ? "<unknown>" : f.description(),
            static_cast<const void*>(
                reinterpret_cast<const char*>(f.native_handle())));
    } else {
        std::println("  #{}: {} ({}:{})",
            i, f.description(), f.source_file(), f.source_line());
    }
}

Depth-limited assertion macro

cpp
#define ASSERT(cond)                                                        \
    do {                                                                    \
        if (!(cond)) {                                                      \
            /* skip=1 omits the macro's expansion frame; max 16 frames */  \
            auto t = std::stacktrace::current(1, 16);                      \
            std::println(stderr, "assertion failed: {}\n{}", #cond, t);    \
            std::abort();                                                   \
        }                                                                   \
    } while (false)

Custom allocator for constrained environments

std::basic_stacktrace<Allocator> accepts any Allocator meeting the standard requirements, enabling use with memory pools, arenas, or PMR resources:

cpp
#include <memory_resource>
#include <stacktrace>

// Fixed 4 KiB pool on the stack — no heap allocation
std::byte buf[4096];
std::pmr::monotonic_buffer_resource pool{buf, sizeof buf};
std::pmr::polymorphic_allocator<std::stacktrace_entry> alloc{&pool};

using PmrTrace = std::basic_stacktrace<
    std::pmr::polymorphic_allocator<std::stacktrace_entry>>;

auto trace = PmrTrace::current(0, 32, alloc);

Capturing Inside Exception Handlers

A common but incorrect pattern is to call std::stacktrace::current() inside a catch block hoping to see where the exception came from. This does not work — stack unwinding has already destroyed the frames between the throw site and the handler. What you capture is the handler's own narrow context.

cpp
// WRONG — this trace shows the catch block, not the throw site
try {
    some_operation();
} catch (const std::exception& e) {
    // C++23
    auto trace = std::stacktrace::current();  // only the catch frame is visible
    std::println(stderr, "caught: {}\n{}", e.what(), trace);
}

The correct technique is to capture the trace at the throw site, before unwinding begins. The cleanest way is to embed the trace in the exception object itself:

cpp
// RIGHT — trace captured before any unwinding
#include <stacktrace>
#include <stdexcept>
#include <print>   // C++23

class TracedError : public std::runtime_error {
    std::stacktrace trace_;
public:
    // skip=1 omits this constructor frame so index 0 is the actual throw site
    explicit TracedError(std::string_view msg, std::size_t extra_skip = 0)
        : std::runtime_error{std::string{msg}}
        , trace_{std::stacktrace::current(1 + extra_skip)}
    {}

    [[nodiscard]] const std::stacktrace& trace() const noexcept { return trace_; }
};

// --- usage ---

void load_config(std::string_view path) {
    if (path.empty())
        throw TracedError{"config path is empty"};
        // trace_[0] == this line in load_config(), not in TracedError's ctor
}

int main() {
    try {
        load_config("");
    } catch (const TracedError& e) {
        std::println(stderr, "error: {}", e.what());
        std::println(stderr, "thrown from:\n{}", e.trace());
        // Output (with -g):
        //  0: load_config(std::string_view) at main.cpp:14
        //  1: main at main.cpp:20
    }
}

skip=1 in the constructor body is the key detail. Without it, trace_[0] would be the TracedError constructor itself — an implementation detail you never want to see in diagnostic output. Add extra_skip to let subclasses or factory functions strip their own frames as well.

std::stacktrace_entry Deep Dive

Each element of a std::stacktrace is a std::stacktrace_entry. Its three metadata accessors — description(), source_file(), and source_line() — are defined by the standard but their content is implementation-defined. Understanding what each one returns (and when it returns nothing) is essential for writing robust diagnostic code.

description() returns a std::string containing the function name. On GCC and Clang with -g, this is usually the demangled signature (e.g., void server::handle_request(std::string_view)). Without debug symbols, it may be a mangled symbol name like _ZN6server14handle_requestB..., a raw hex address, or an empty string. MSVC typically returns the undecorated name from the PDB.

source_file() returns the absolute path as recorded in the debug information — the path the compiler saw at build time. In CI environments this is often a build-agent path that does not exist on the developer's machine. Strip it to a relative path before logging to users.

source_line() is a uint_least32_t (minimum 32 bits, 1-based). It returns 0 when no line information is available.

Iterating and filtering frames

A common need is to skip standard library internals and show only application frames. Since source_file() exposes the full path, you can filter by path prefix:

cpp
#include <stacktrace>
#include <string_view>
#include <print>   // C++23

void print_app_frames(const std::stacktrace& st,
                      std::string_view project_root) {
    std::size_t shown = 0;
    for (const auto& frame : st) {
        // Skip frames outside the project tree (stdlib, system libs, etc.)
        if (!frame.source_file().starts_with(project_root))
            continue;

        std::println("  #{}: {} ({}:{})",
            shown++,
            frame.description().empty() ? "<unknown>" : frame.description(),
            frame.source_file().substr(project_root.size()),  // relative path
            frame.source_line());
    }
    if (shown == 0)
        std::println("  (no application frames — rebuild with -g)");
}

// usage
auto trace = std::stacktrace::current(1);
print_app_frames(trace, "/home/user/myproject/");

Formatting individual entries

std::formatter<std::stacktrace_entry> is provided in C++23, so individual frames integrate seamlessly with std::format:

cpp
// C++23
auto st = std::stacktrace::current();
for (std::size_t i = 0; i < st.size(); ++i) {
    // Default format: "description (file:line)" or just "description" if no source info
    std::string line = std::format("  [{}] {}", i, st[i]);
    write_to_log(line);
}

The default format produced by std::formatter<std::stacktrace_entry> is implementation-defined. GCC tends to produce description at file:line; MSVC tends to produce description [file line]. Do not parse this string — access the individual members if you need structured data.

Performance Characteristics

std::stacktrace::current() performs a runtime stack walk. The cost depends on depth and whether symbol resolution is deferred, but in practice it is in the microsecond to low-millisecond range — several orders of magnitude slower than most function calls. It must never appear in a tight loop or on a hot path.

cpp
// WRONG — thousands of stack walks per second in a hot loop
for (auto& item : large_dataset) {
    auto ctx = std::stacktrace::current();   // microseconds each time
    process(item, ctx);
}

// RIGHT — capture once outside the loop
auto ctx = std::stacktrace::current(1);
for (auto& item : large_dataset) {
    process(item, ctx);
}

Contrast with std::source_location. std::source_location::current() is consteval — it is evaluated at compile time and costs exactly zero at runtime. Use source_location for file/line attribution in logging functions. Reserve stacktrace for exception paths, crash reporters, and diagnostics where the full call chain is genuinely needed.

ToolCostWhat you getUse when
std::source_locationZero (compile-time)One file + line + functionLogging, assertions
std::stacktraceMicroseconds–msFull call chain at runtimeExceptions, crash reports

Depth limits matter. Walking 8 frames is far cheaper than walking 128. In a production exception path where you call stacktrace::current() infrequently, a limit of 16–32 is a good balance between coverage and cost:

cpp
// C++23
// Limit to 24 frames — enough for most real backtraces, avoids deep recursion cost
trace_{std::stacktrace::current(1, 24)}

Symbol resolution (turning raw instruction pointers into description() / source_file() / source_line()) is the expensive part. On GCC's lstdc++exp backend it is typically done eagerly at capture time; some implementations defer it. Either way, avoid calling description() or source_file() in hot code paths.

PMR Allocator for Stacktrace

std::basic_stacktrace<Allocator> is parameterized on its allocator. The PMR variant avoids heap allocation entirely by drawing from a pre-allocated buffer — important in allocator-sensitive code paths such as embedded systems, real-time signal handlers, or custom memory arenas.

cpp
#include <memory_resource>
#include <stacktrace>
#include <print>   // C++23

// Convenience alias for the PMR stacktrace type
using PmrStacktrace = std::basic_stacktrace<
    std::pmr::polymorphic_allocator<std::stacktrace_entry>>;

void diagnose_in_arena() {
    // 8 KiB stack buffer — sufficient for ~200 entries on 64-bit platforms
    alignas(std::max_align_t) std::byte buf[8192];
    std::pmr::monotonic_buffer_resource pool{buf, sizeof buf,
        std::pmr::null_memory_resource()};  // crash rather than fall back to heap
    std::pmr::polymorphic_allocator<std::stacktrace_entry> alloc{&pool};

    // Capture up to 32 frames using the arena
    auto trace = PmrStacktrace::current(1, 32, alloc);

    // trace's entries live in buf; no heap allocation occurred
    std::println("{}", trace);
}   // pool and buf destroyed together — no individual deallocation needed

std::pmr::null_memory_resource() as the upstream is a deliberate choice here: if the 8 KiB arena fills (e.g., because max_depth was set too high or stacktrace_entry is unusually large on this platform), the program terminates instead of silently falling back to the heap. Replace with std::pmr::get_default_resource() if graceful degradation is preferred.

PmrStacktrace is not implicitly convertible to std::stacktrace. If you need to pass it to a function that accepts const std::stacktrace&, copy it into a heap-backed std::stacktrace first.

Integration with Logging

std::formatter<std::stacktrace> is defined in C++23, so a full stacktrace can be embedded directly in a std::format or std::println call without any manual loop. This makes it straightforward to build a logging macro that emits a complete backtrace.

cpp
#include <stacktrace>
#include <format>
#include <print>    // C++23
#include <source_location>
#include <string_view>

// write_log() — send a formatted string to your logging backend
void write_log(std::string_view message) {
    // In a real project, replace with your actual log sink
    std::println(stderr, "{}", message);
}

// LOG_WITH_TRACE — captures both the call-site location (compile-time, zero cost)
// and the full runtime stack (for deep diagnostics).
// Use only at Error/Fatal level — see performance section.
#define LOG_WITH_TRACE(msg)                                                   \
    do {                                                                      \
        auto _loc = std::source_location::current();                          \
        auto _st  = std::stacktrace::current(1);  /* skip this macro frame */\
        write_log(std::format("[{}:{}] {}\n{}", _loc.file_name(),             \
                              _loc.line(), (msg), _st));                      \
    } while (false)

The std::formatter<std::stacktrace> specialization formats the entire trace as a multi-line string. The exact format is implementation-defined, but GCC produces output like:

cpp
   0#  validate(int) at src/validation.cpp:42
   1#  process_request(Request const&) at src/server.cpp:117
   2#  main at src/main.cpp:55

For structured log formats (JSON, NDJSON, structured syslog), iterate over the entries manually and serialize description(), source_file(), and source_line() as individual fields rather than embedding the free-form string.

cpp
// C++23 — JSON-style structured log entry
std::string format_trace_json(const std::stacktrace& st) {
    std::string out = "[";
    for (std::size_t i = 0; i < st.size(); ++i) {
        const auto& f = st[i];
        if (i > 0) out += ',';
        out += std::format(
            R"({{"frame":{},"fn":"{}","file":"{}","line":{}}})",
            i, f.description(), f.source_file(), f.source_line());
    }
    out += ']';
    return out;
}

std::stacktrace in Assertion Macros

The standard assert() macro terminates with a message like assertion "ptr != nullptr" failed but provides no call stack. Replacing it with a custom ASSERT macro that captures a stacktrace before aborting makes post-mortem debugging significantly faster — especially in headless environments where attaching a debugger is not possible.

cpp
#include <stacktrace>
#include <source_location>
#include <print>    // C++23
#include <cstdlib>

// Internal implementation — not called directly
[[noreturn]] inline void assert_fail_impl(
    const char* expr_str,
    std::source_location loc,
    std::stacktrace trace) noexcept
{
    std::println(stderr,
        "\nAssertion failed: {}"
        "\n  at {}:{} in {}"
        "\nStack trace:"
        "\n{}",
        expr_str,
        loc.file_name(), loc.line(), loc.function_name(),
        trace);
    std::fflush(stderr);
    std::abort();
}

// Public macro — the only macro needed; stringification requires it
// skip=1 removes the assert_fail_impl frame; max=20 keeps output readable
#define ASSERT(expr)                                                          \
    do {                                                                      \
        if (!(expr)) [[unlikely]] {                                           \
            assert_fail_impl(                                                 \
                #expr,                                                        \
                std::source_location::current(),                              \
                std::stacktrace::current(1, 20));                             \
        }                                                                     \
    } while (false)

The combination of std::source_location::current() and std::stacktrace::current() gives you two independent perspectives:

  • source_location identifies the exact line of the ASSERT(...) call at zero runtime cost.
  • stacktrace gives the full path of callers that led to the failure, at the cost of the stack walk — paid only when the assertion actually fires.

Because the stack walk only occurs on failure, the overhead in normal (passing) execution is negligible — just a branch prediction miss on the [[unlikely]] path.

cpp
// Example output:
//
// Assertion failed: ptr != nullptr
//   at src/parser.cpp:88 in Token* Parser::advance()
// Stack trace:
//    0#  Parser::advance() at src/parser.cpp:88
//    1#  Parser::parse_expression() at src/parser.cpp:133
//    2#  Parser::parse_statement() at src/parser.cpp:201
//    3#  main at src/main.cpp:14

Compiler and Linker Setup

<stacktrace> depends on a runtime symbol resolution backend that must be explicitly linked on some toolchains. Without the correct flags, compilation succeeds but the resulting trace will have empty description(), source_file(), and source_line() values — the capture silently degrades to raw addresses.

GCC

GCC 12 and 13 ship the implementation in a separate experimental library:

sh
# Compile with debug symbols + link the experimental runtime
g++ -std=c++23 -g main.cpp -lstdc++exp -o myapp

GCC 14 merged the implementation into the standard runtime. The extra link flag is no longer needed, but -g remains essential for file/line information:

sh
# GCC 14+
g++ -std=c++23 -g main.cpp -o myapp

An alternative backend based on libbacktrace is available on some distributions:

sh
# libbacktrace-based backend (GCC 12/13 on some distros)
g++ -std=c++23 -g main.cpp -lstdc++_libbacktrace -o myapp

Clang

As of mid-2025, Clang's libc++ implementation of <stacktrace> is incomplete. Check __cpp_lib_stacktrace before relying on it:

cpp
#if __cpp_lib_stacktrace >= 202011L
    // std::stacktrace is available and conforming
#else
    #error "std::stacktrace not supported on this toolchain"
#endif

When libc++ support matures, linking is expected to be transparent when using -stdlib=libc++. For now, if you target Clang, use the GCC-based toolchain on Linux or wait for your libc++ version to carry a stable implementation.

MSVC

MSVC (VS 2022 17.5 / toolset 19.35+) supports <stacktrace> out of the box — no extra library link is needed. You must compile with /Zi (or /Z7) and link with /DEBUG to get symbol information:

cpp
cl /std:c++latest /Zi /EHsc main.cpp /link /DEBUG:FULL /out:myapp.exe

CMake snippet

cmake
cmake_minimum_required(VERSION 3.25)
project(myapp LANGUAGES CXX)
set(CMAKE_CXX_STANDARD 23)

add_executable(myapp main.cpp)

# Debug symbols — required for meaningful file/line output
target_compile_options(myapp PRIVATE
    $<$<CXX_COMPILER_ID:GNU,Clang>:-g>
    $<$<CXX_COMPILER_ID:MSVC>:/Zi>
)

# GCC 12/13: experimental stacktrace runtime
if (CMAKE_CXX_COMPILER_ID STREQUAL "GNU" AND
    CMAKE_CXX_COMPILER_VERSION VERSION_LESS "14")
    target_link_libraries(myapp PRIVATE stdc++exp)
endif()

# MSVC: emit full debug info into the binary
target_link_options(myapp PRIVATE
    $<$<CXX_COMPILER_ID:MSVC>:/DEBUG:FULL>
)

Best Practices

Capture at throw, not catch. This is the single most important rule. Always construct the std::stacktrace in the code path that throws — ideally in the exception's constructor — never inside a catch handler where the interesting frames are already gone.

Use skip=1 in exception constructors. The TracedError / TracedException pattern above always passes skip=1 so that trace_[0] is the throw site, not the exception class internals. Add an extra_skip parameter to let subclasses strip their own frames without hard-coding the depth.

Tune skip to remove infrastructure noise. Every logging or assertion helper adds frames that pollute the trace. Pass skip=N where N equals the number of your own infrastructure frames so that index 0 in the output is always user code.

Limit max_depth in production. Walking and resolving 64 frames per exception in a high-throughput path adds measurable latency. Restrict to 16–32 frames for production exception paths; reserve unlimited depth for crash-report or startup-time diagnostics where overhead is irrelevant.

Defer formatting, not capture. Store the std::stacktrace object immediately (while the stack is intact and addresses are valid), then call description() / source_file() lazily or in a background reporting thread. Resolving symbols is typically more expensive than recording raw addresses.

Gate on a debug/verbosity flag in hot code. Even a depth-limited capture has non-trivial cost inside tight loops. Wrap with #ifndef NDEBUG or a runtime verbosity level to avoid paying for it unconditionally.

Prefer std::source_location when a single frame is enough. If you only need the call site of a logging or assertion function, source_location is zero-cost. Reach for stacktrace only when the full call chain is genuinely diagnostic.

Common Pitfalls

Catching before tracing. Calling std::stacktrace::current() inside a catch block records the handler's frame, not the throw site. The information you actually want is already gone by then.

Missing linker support. The implementation requires a runtime symbol backend:

  • GCC 12–13: link -lstdc++exp (experimental runtime); requires -g for file/line
  • GCC 14+: included in the standard library; still needs -g
  • MSVC 19.35+ (VS 2022 17.5+): requires /Zi compile flag and /DEBUG linker flag
  • Clang/libc++: as of 2025, <stacktrace> support remains incomplete — verify your toolchain before shipping

Release builds with stripped symbols. Without -g (GCC/Clang) or /Zi /DEBUG (MSVC), description() returns an empty string or a raw hex address, source_file() returns "", and source_line() returns 0. The native handles are still valid for offline symbolication against a symbol map, but the trace is not human-readable at runtime.

Treating native_handle_type as portable. It is strictly implementation-defined. Do not cast it to uintptr_t and assume it represents an instruction pointer across compilers or platforms. Use it only for logging or for passing to platform-specific resolvers (dladdr on POSIX, SymFromAddr on Windows).

Storing deep traces in every exception object. Copying 64 entries into every exception in a high-frequency path adds allocation and copy overhead. Prefer a small max_depth or guard capture behind a debug flag.

PmrStacktrace is not std::stacktrace. The PMR alias and the default alias are distinct instantiations of std::basic_stacktrace. They do not share a common base class and are not implicitly convertible. If you need to pass a PMR-backed trace into an API that accepts const std::stacktrace&, you must copy it.

description() may be mangled. Some implementations return mangled C++ symbol names (starting with _Z on GCC/Clang ABI) when the demangling library is not linked or the symbol is in a translation unit compiled without -g. Do not rely on the format for parsing — use it only for display.

See Also

  • std::source_location — compile-time file/line at zero runtime cost; ideal when a full stack trace is overkill
  • std::exception — base class for building traced exception hierarchies
  • std::format — C++20 text formatting; std::stacktrace and std::stacktrace_entry provide std::formatter specializations in C++23
  • <print> — C++23 std::println / std::print used throughout the examples above