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