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

<spanstream>

C++23 header providing stream I/O over std::span<char> buffers — fixed-capacity, zero-allocation alternatives to <sstream>.

<spanstream>since C++23

A header introducing stream classes backed by std::span<char>, enabling standard-stream I/O over caller-owned memory with no dynamic allocation.

Overview

<spanstream> fills the gap between <sstream> and raw memcpy. It gives you the full std::basic_istream/std::basic_ostream interface — >>, <<, getline, seekg, manipulators — without touching the heap. The backing buffer is a std::span<char> supplied by the caller; the stream never reallocates or resizes it.

This matters in three common scenarios:

  • Embedded / real-time code where heap allocation is forbidden or non-deterministic.
  • Protocol parsing where you already have a fixed-size wire-format packet and want to extract fields with type-safe stream operators.
  • Stack-allocated formatting where you need to build a string into a local array and hand a std::string_view back to the caller.

The header provides four class templates and eight char/wchar_t typedefs:

Templatechar typedefwchar_t typedefRole
std::basic_spanbuf<C,T>spanbufwspanbufStream buffer backed by span
std::basic_ispanstream<C,T>ispanstreamwispanstreamInput-only stream
std::basic_ospanstream<C,T>ospanstreamwospanstreamOutput-only stream
std::basic_spanstream<C,T>spanstreamwspanstreamBidirectional stream

All four inherit from the usual basic_istream/basic_ostream/basic_iostream hierarchy, so existing code that accepts stream references works without modification.

Ownership and lifetime

The stream does not own the underlying buffer. The std::span<char> you pass in must remain alive and unmodified for the entire lifetime of the stream object. Violating this is undefined behaviour.

For input streams (ispanstream), the constructor accepts std::span<const char>. For output and bidirectional streams, the span must be span<char> because the stream writes into it.

The .span() accessor

After writing with ospanstream or spanstream, the member function .span() returns a std::span<char> whose size reflects the number of bytes written so far — not the full buffer capacity. This is the idiomatic way to get a string_view over the formatted output:

cpp
// since C++23
char buf[128];
std::ospanstream out{buf};
out << "errno=" << 42 << " flags=0x" << std::hex << 0xFF;
std::string_view result{out.span().data(), out.span().size()};
// result == "errno=42 flags=0xff"

Syntax

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

// Construction
std::ispanstream  in { std::span<const char>{data, n} };
std::ospanstream  out{ std::span<char>{buf, sizeof buf} };
std::spanstream   io { std::span<char>{buf, sizeof buf}, std::ios::in | std::ios::out };

// Querying written region
std::span<char> written = out.span();        // sub-span up to put pointer

// Replacing the backing buffer
out.span( std::span<char>{new_buf, N} );     // resets stream state and buffer

// Free swap (ADL)
swap(a, b);   // std::swap specialisation provided

The openmode argument to spanstream follows the same rules as stringstream: it defaults to ios::in | ios::out.

Examples

Parsing a fixed-format packet

cpp
#include <spanstream>  // C++23
#include <cstdint>
#include <array>

struct SensorPacket {
    std::uint16_t device_id;
    float         temperature;
    std::uint8_t  flags;
};

// Wire format: "DEV=0042 TEMP=21.50 FL=03\n"
bool parse_packet(std::span<const char> wire, SensorPacket& out) {
    std::ispanstream in{wire};
    std::string tag;

    if (!(in >> tag) || tag != "DEV=") return false;
    if (!(in >> out.device_id))        return false;
    if (!(in >> tag) || tag != "TEMP=") return false;
    if (!(in >> out.temperature))      return false;
    if (!(in >> tag) || tag != "FL=")  return false;
    unsigned tmp;
    if (!(in >> std::hex >> tmp))      return false;
    out.flags = static_cast<std::uint8_t>(tmp);
    return true;
}

ispanstream performs no allocation here. The packet buffer lives wherever the caller put it — stack, static, DMA region — and the stream reads directly from it.

Zero-allocation formatting into a stack buffer

cpp
#include <spanstream>  // C++23
#include <string_view>

// Returns a string_view into the local buffer — caller must not escape it.
void log_event(std::uint32_t code, std::string_view msg) {
    char buf[256];
    std::ospanstream out{buf};
    out << "[0x" << std::hex << std::uppercase << code << "] " << msg;

    if (out.fail()) {
        // Buffer overrun: put pointer hit the end and badbit set.
        // Truncate gracefully.
    }

    std::string_view formatted{out.span().data(), out.span().size()};
    write_log(formatted);   // pass to whatever sink
}

Bidirectional in-place editing

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

// Re-format a fixed record in place: swap two space-separated tokens.
void swap_tokens(std::span<char> record) {
    std::spanstream io{record};
    std::string a, b;
    io >> a >> b;

    io.seekp(0);
    io << b << ' ' << a;
    // Pad to original length so record size is unchanged.
    auto pos = static_cast<std::size_t>(io.tellp());
    while (pos < record.size()) { io.put(' '); ++pos; }
}

Best Practices

Check for overflow. When ospanstream exhausts the buffer, it sets badbit and failbit. Subsequent writes silently do nothing. Always test out.fail() or out.good() after writing, especially when the output length is not statically bounded.

Use .span() not .span().data() alone. The returned span carries the correct size. Converting only the pointer to const char* will read past the written region unless you also capture .size() or null-terminate manually.

Prefer ispanstream over stringstream for read-only parsing. The std::stringstream constructor copies its std::string argument; ispanstream does not. When parsing a large buffer, this avoids a potentially costly allocation-and-copy.

Do not let the span outlive its source. If you construct an ispanstream from a temporary std::string, that string must not be destroyed while the stream is in use. Pin the lifetime explicitly.

wchar_t streams require a wide buffer. wspanstream operates on std::span<wchar_t>, not std::span<char>. Wide and narrow variants are not interchangeable.

Common Pitfalls

Buffer size is in elements, not bytes. std::span<char>{buf, N} treats N as the number of char elements. Passing sizeof buf is correct for char arrays but would be wrong for wchar_t arrays where sizeof returns byte count.

badbit after overflow is sticky. After ospanstream overflows, no amount of seekp or clear will make subsequent writes succeed unless you call span(new_buf) to reset the stream entirely. If you need overflow recovery, supply a larger buffer upfront.

seekp(0) does not reset the written region reported by .span(). The put pointer moves, but .span() reflects the high-water mark of the put pointer, not its current position. If you seek back and overwrite, .span() still shows the original length until you provide a new backing span.

Missing #include <spanstream>. std::span is in <span> (C++20); std::spanstream is in a separate <spanstream> header. Including one does not pull in the other.

std::ispanstream — Read-Only Parsing

std::ispanstream (a typedef for std::basic_ispanstream<char>) wraps a read-only std::span<const char> and gives you the full input-stream interface without copying or allocating. It is the zero-allocation counterpart to std::istringstream.

Construction

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

// From a raw array — span deduced automatically
const char data[] = "42 hello 3.14";
std::ispanstream in1{data};

// From a string_view (via the range-of-span constructor)
std::string_view sv = "42 hello 3.14";
std::ispanstream in2{std::span<const char>{sv.data(), sv.size()}};

// From an existing span<const char>
std::span<const char> s{data};
std::ispanstream in3{s};

The .span() accessor on ispanstream returns std::span<const char> covering the entire buffer (not just the unconsumed remainder). To find out how many bytes have been consumed, query in.tellg().

Extracting tokens and numbers

cpp
#include <spanstream>  // C++23
#include <string>

void demo_parse() {
    const char raw[] = "100 3.14 world";
    std::ispanstream in{raw};

    int         n;
    double      d;
    std::string word;

    in >> n >> d >> word;
    // n == 100, d == 3.14, word == "world"
    // in.eof() == true, in.fail() == false
}

ispanstream accepts the same manipulators and flags as istringstream: std::hex, std::skipws, std::noskipws, std::setw, etc.

Replacing the buffer without reallocating

Both ispanstream and ospanstream expose a span(new_span) setter that resets the buffer and all stream-state flags. This lets you reuse a single stream object across multiple messages — an important pattern in poll-loop protocol handlers:

cpp
#include <spanstream>  // C++23
#include <array>
#include <cstdint>

struct MsgHeader {
    std::uint16_t type;
    std::uint16_t length;
};

MsgHeader parse_header(std::span<const char> buf) {
    std::ispanstream in{buf};
    MsgHeader hdr{};
    in >> hdr.type >> hdr.length;
    return hdr;
}

// Reuse with different buffer:
void process_messages(std::span<const char> pkt1,
                      std::span<const char> pkt2) {
    std::ispanstream in{pkt1};
    // ... parse pkt1 ...

    in.span(std::span<char>{
        const_cast<char*>(pkt2.data()), pkt2.size()
    });
    // stream state is fully reset; pkt2 is now the source
}

std::spanbuf — The Raw Buffer Layer

std::spanbuf (typedef for std::basic_spanbuf<char>) is the streambuf subclass that the three stream classes wrap. You will rarely construct one directly, but understanding its interface clarifies several spanstream behaviours.

What spanbuf exposes

MemberWhat it does
spanbuf()Default-construct with an empty span (no buffer attached)
spanbuf(span<char> s, openmode m)Attach s as the backing store with mode m
span()Get current span — for input mode: full buffer; for output mode: written region
span(span<char> s)Replace the backing store, resetting get/put pointers
setbuf(char*, streamsize) (protected)Called by stream machinery; rewires the buffer pointers
seekoff / seekpos (protected)Implements seekg/seekp relative and absolute seeks

spanbuf is move-constructible and move-assignable but not copyable — the same restriction as span itself.

Direct spanbuf usage

The main reason to reach for spanbuf directly is when you want to attach it to a std::basic_iostream that you already have, or when you need to use it through a std::streambuf* interface (for example, passing it to std::ostream at construction):

cpp
#include <spanstream>  // C++23
#include <ostream>
#include <array>

// Use spanbuf as a custom ostream buffer
std::array<char, 64> buf{};
std::spanbuf sb{buf, std::ios::out};
std::ostream os{&sb};    // attach via streambuf* constructor

os << "value=" << 99;

// Retrieve the written span
std::span<char> written = sb.span();   // size == 8: "value=99"

You can also inspect or pre-populate the buffer before attaching it to a stream:

cpp
std::array<char, 16> arr{};
std::fill(arr.begin(), arr.end(), 'X');   // sentinel fill

std::spanbuf sb{arr, std::ios::in | std::ios::out};
sb.span(arr);   // re-attach to reset pointers

Performance Trade-Offs

Three tools occupy different points on the allocation vs. convenience spectrum. Knowing which to reach for avoids unnecessary complexity.

The three options

FacilityAllocationLocale overheadConvenienceBest for
std::stringstreamYes — heap (new)YesHighGeneral-purpose formatting/parsing; unknown output size
std::spanstreamNo — caller's bufferYesHighFormatting/parsing into a known-size buffer; drop-in sstream replacement
std::from_chars / to_charsNoNoLowMaximum numeric throughput; locale-independence required

Why stringstream allocates more than you expect

Constructing a std::stringstream allocates a 512-byte std::string internal buffer by default (implementation-defined, but 512 bytes is common on libstdc++/libc++). Every call to str() to retrieve the result performs another allocation and copy. C++20 mitigated the latter via view() and move-constructor, but the initial buffer allocation remains.

Confirmed by the cppstories.com instrumented-allocator experiment: a fresh stringstream triggers a 513-byte allocation before any data is written. spanstream triggers none.

Why spanstream is still slower than from_chars for numbers

Both spanstream and stringstream invoke the stream locale machinery for numeric formatting and parsing. This involves virtual dispatch into std::num_put / std::num_get facets even in the default "C" locale. For high-throughput numeric work, this overhead is measurable.

std::from_chars / std::to_chars (C++17, <charconv>) bypass the locale entirely:

cpp
#include <charconv>    // C++17
#include <spanstream>  // C++23

// from_chars: fastest, no locale, no allocation
int parse_fast(std::string_view sv) {
    int result{};
    std::from_chars(sv.data(), sv.data() + sv.size(), result);
    return result;
}

// ispanstream: convenient, handles whitespace/tokens, uses locale
int parse_convenient(std::string_view sv) {
    std::ispanstream in{std::span<const char>{sv.data(), sv.size()}};
    int result{};
    in >> result;
    return result;
}

Decision guide

Use from_chars / to_chars when:

  • You are parsing a single number from a known-position byte range.
  • You need locale-independence (e.g., decimal separator must always be .).
  • You are in a hot loop parsing thousands of records per second.

Use ispanstream / ospanstream when:

  • You need to parse multiple whitespace-separated tokens of mixed types in one pass.
  • You want to use manipulators (std::hex, std::setw, std::setprecision).
  • You are porting code from stringstream and want a zero-allocation drop-in.

Use stringstream when:

  • You do not know the output size at compile time and cannot bound it.
  • You are not in a performance-sensitive path.

Embedded / No-Allocator Use Case

In firmware and real-time systems where operator new is forbidden or non-deterministic, spanstream covers the gap between "raw memcpy" and "full stream convenience". The pattern below is a complete, reusable, zero-allocation protocol parser for a simple ASCII frame format.

Stack-allocated frame parser

cpp
// C++23  —  zero heap allocation throughout
#include <spanstream>
#include <cstdint>
#include <array>
#include <string_view>

// ASCII frame: "CMD:0x%02X LEN:%u DATA:%s\n"
// Example:     "CMD:0x1F LEN:3 DATA:abc\n"

struct Frame {
    std::uint8_t  cmd;
    std::uint8_t  len;
    char          data[32];
    bool          valid;
};

// parse_frame() operates entirely on the caller-supplied buffer.
// No allocation, no exception (stream errors set .valid = false).
Frame parse_frame(std::span<const char> buf) {
    Frame f{};
    std::ispanstream in{buf};

    std::string_view tag;
    // Extract "CMD:" prefix and hex byte
    char scratch[8]{};
    std::span<char> sc{scratch};

    // Read token "CMD:0x1F" into scratch then parse
    char tok[16]{};
    in.getline(tok, sizeof tok, ' ');  // up to first space
    // tok == "CMD:0x1F"
    std::string_view sv{tok};
    if (!sv.starts_with("CMD:0x")) { return f; }
    sv.remove_prefix(6);
    auto [end1, ec1] = std::from_chars(sv.data(),
                                       sv.data() + sv.size(),
                                       f.cmd, 16);
    if (ec1 != std::errc{}) { return f; }

    // Read "LEN:3"
    in.getline(tok, sizeof tok, ' ');
    sv = {tok};
    if (!sv.starts_with("LEN:")) { return f; }
    sv.remove_prefix(4);
    std::from_chars(sv.data(), sv.data() + sv.size(), f.len);

    // Read "DATA:abc"
    in.getline(tok, sizeof tok, '\n');
    sv = {tok};
    if (!sv.starts_with("DATA:")) { return f; }
    sv.remove_prefix(5);
    if (sv.size() >= sizeof f.data) { return f; }
    sv.copy(f.data, sv.size());
    f.data[sv.size()] = '\0';

    f.valid = in.good() || in.eof();
    return f;
}

This example illustrates the hybrid idiom common in embedded C++23: ispanstream handles tokenization and positional scanning; std::from_chars handles numeric conversion without locale. Each layer does what it does best, and the stack buffer never escapes.

Output side: building a response in-place

cpp
// C++23  —  format a response frame into a stack buffer,
// then hand a string_view to the UART transmit function.
#include <spanstream>  // C++23
#include <charconv>    // C++17
#include <string_view>
#include <cstdint>

void send_ack(std::uint8_t seq, std::uint16_t checksum,
              void (*uart_write)(std::string_view)) {
    char buf[64];
    std::ospanstream out{buf};
    out << "ACK " << static_cast<unsigned>(seq)
        << " CHK=0x" << std::hex << std::uppercase << checksum
        << '\n';

    if (!out.fail()) {
        uart_write({out.span().data(), out.span().size()});
    } else {
        // Buffer too small — increase buf[] size or truncate
        uart_write("ERR\n");
    }
}

No heap involved. The buf array is on the stack; ospanstream fills it; the caller's uart_write callback receives a string_view into that same stack region. This works because the callback is synchronous — the frame is transmitted before buf goes out of scope.

Compiler support

CompilerMinimum version for <spanstream>
GCC (libstdc++)12 (November 2021)
Clang (libc++)17
MSVC (STL)Visual Studio 2022 17.3 (P0448R4)

All three ship with -std=c++23 (or /std:c++latest on MSVC). The feature-test macro is __cpp_lib_spanstream (value 202106L).

See Also

  • <sstream> — heap-backed string streams; same interface, dynamic allocation
  • <span> (C++20) — the non-owning view type that backs these streams
  • <charconv> — std::from_chars / std::to_chars for locale-independent, allocation-free number conversions; often complementary to spanstream in embedded contexts