<spanstream>
C++23 header providing stream I/O over std::span<char> buffers — fixed-capacity, zero-allocation alternatives to <sstream>.
<spanstream>since C++23A 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_viewback to the caller.
The header provides four class templates and eight char/wchar_t typedefs:
| Template | char typedef | wchar_t typedef | Role |
|---|---|---|---|
std::basic_spanbuf<C,T> | spanbuf | wspanbuf | Stream buffer backed by span |
std::basic_ispanstream<C,T> | ispanstream | wispanstream | Input-only stream |
std::basic_ospanstream<C,T> | ospanstream | wospanstream | Output-only stream |
std::basic_spanstream<C,T> | spanstream | wspanstream | Bidirectional 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:
// 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
// #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 providedThe openmode argument to spanstream follows the same rules as stringstream: it defaults to ios::in | ios::out.
Examples
Parsing a fixed-format packet
#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
#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
#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
// #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
#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:
#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
| Member | What 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):
#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:
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 pointersPerformance Trade-Offs
Three tools occupy different points on the allocation vs. convenience spectrum. Knowing which to reach for avoids unnecessary complexity.
The three options
| Facility | Allocation | Locale overhead | Convenience | Best for |
|---|---|---|---|---|
std::stringstream | Yes — heap (new) | Yes | High | General-purpose formatting/parsing; unknown output size |
std::spanstream | No — caller's buffer | Yes | High | Formatting/parsing into a known-size buffer; drop-in sstream replacement |
std::from_chars / to_chars | No | No | Low | Maximum 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:
#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
stringstreamand 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
// 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
// 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
| Compiler | Minimum 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_charsfor locale-independent, allocation-free number conversions; often complementary tospanstreamin embedded contexts