C++ string vs string_view: Fast, Non-Owning String Handling
Key takeaways
std::string vs std::string_view: avoid copies in read-only APIs, allocation costs, lifetime rules, substring performance, and null-termination caveats.
The Problem: Unnecessary String Copies
A common C++ performance problem is passing strings by value when the function only needs to read them:
// Takes string by value — always copies (or moves, but still an allocation)
void logMessage(std::string msg) {
std::cout << "[LOG] " << msg << '\n';
}
// At the call site:
logMessage("Connection established"); // allocates a std::string, copies chars
logMessage(userInput); // copies userInput into a new std::string
std::string_view (C++17) solves this. It’s a lightweight, non-owning reference to a character sequence — no allocation, no copy:
#include <string_view>
void logMessage(std::string_view msg) {
std::cout << "[LOG] " << msg << '\n';
}
logMessage("Connection established"); // no allocation — points to literal
logMessage(userInput); // no copy — points into userInput's buffer
How much the by-value version actually costs depends on the string’s length, because of the small string optimization (SSO): std::string stores short contents inside the object itself, without a heap allocation. The threshold is 15 characters in libstdc++ and MSVC, and 22 in libc++. “Connection established” is 22 characters, so the by-value call allocates on GCC and MSVC but not on Clang with libc++ — the kind of detail that makes a micro-benchmark on one platform misleading on another. Either way, it still copies the characters, and string_view removes both costs.
A const std::string& parameter was the pre-C++17 answer and avoids the copy for callers that already have a std::string. Its weakness is exactly the literal case: passing "Connection established" to a const std::string& constructs a temporary std::string first. string_view accepts literals, std::string, char buffers with a length, and other views uniformly, with no temporary.
What string_view Actually Is
string_view is essentially a struct with two fields:
struct string_view {
const char* ptr; // pointer to character data (not necessarily null-terminated)
size_t len; // number of characters
};
Creating one from any string-like source is O(1):
#include <string>
#include <string_view>
const char* literal = "hello";
std::string stdString = "world";
std::string_view sv1 = literal; // points to literal, len=5
std::string_view sv2 = stdString; // points to stdString's buffer
std::string_view sv3{"hello", 3}; // points to "hel" — first 3 chars only
std::string_view sv4 = sv1.substr(1, 3); // "ell" — no allocation
Constructing from a const char* without a length is O(n), not O(1), strictly speaking: the constructor calls strlen to find the end. For literals the compiler usually computes that at compile time, and constexpr std::string_view makes it guaranteed. The {"hello", 3} form never scans, which is how you build views over binary data or buffers that contain '\0' characters. sv4 shows the other defining property: a view of a view still points into the original characters, so every view derived from literal depends on the same underlying storage.
Comparison Table
| Property | std::string | std::string_view |
|---|---|---|
| Ownership | Owns the buffer | Non-owning |
| Allocation | Heap (unless SSO) | None |
| Construction from literal | O(n) copy | O(1) pointer+length |
| Mutability | Mutable | Read-only |
| Null-terminated | Yes (.c_str()) | Not guaranteed |
| Size | ~24-32 bytes (+ heap) | 16 bytes (2 words) |
| substr() | New allocation | O(1) — returns view |
| Safe to store | Always | Only if source outlives |
| Available since | C++98 | C++17 |
Where string_view Wins: Substring Performance
std::string::substr always allocates a new string. std::string_view::substr just adjusts the pointer and length — no allocation:
#include <string>
#include <string_view>
#include <iostream>
std::string text = "The quick brown fox jumps over the lazy dog";
// string::substr — allocates a new string
std::string sub1 = text.substr(4, 5); // "quick" — heap allocation
// string_view::substr — zero allocation
std::string_view view = text;
std::string_view sub2 = view.substr(4, 5); // "quick" — just pointer+length
std::cout << sub2 << '\n'; // works fine for read-only use
For parsing-heavy code that slices strings frequently (CSV parsers, HTTP header parsers, tokenizers), this difference can be significant.
Tokenizing with string_view
#include <string_view>
#include <vector>
#include <iostream>
std::vector<std::string_view> split(std::string_view s, char delim) {
std::vector<std::string_view> parts;
size_t start = 0;
size_t pos;
while ((pos = s.find(delim, start)) != std::string_view::npos) {
parts.push_back(s.substr(start, pos - start));
start = pos + 1;
}
parts.push_back(s.substr(start));
return parts;
}
int main() {
std::string csv = "alice,bob,charlie,dave";
// All the string_views point into csv — zero copies
for (auto token : split(csv, ',')) {
std::cout << token << '\n';
}
// alice / bob / charlie / dave
}
The returned tokens are only valid while csv is alive and unmodified. The signature does not say so, which is the price of returning views: split(readLine(), ',') compiles, returns views into a temporary std::string that is destroyed at the end of the full expression, and the loop then prints garbage or crashes. A few ways to make this safer: return std::vector<std::string> from functions whose input may be a temporary; keep view-returning helpers for internal parsing where the buffer’s lifetime is obvious; or add a deleted overload, split(std::string&&, char) = delete;, so passing a temporary std::string is a compile error.
For parsing, the trade-off usually favors views anyway. A tokenizer that produces thousands of small std::strings spends much of its time in the allocator, while views cost a pointer and a length each. Parse into views, and copy into std::string only the pieces you keep beyond the buffer’s lifetime.
Parameter Guidelines
The choice of parameter type depends on what the function does with the string:
// Read-only: use string_view — accepts string, literal, string_view, char* all without copy
void print(std::string_view s);
bool contains(std::string_view haystack, std::string_view needle);
size_t count(std::string_view s, char c);
// Mutate in place: must use string reference
void toLower(std::string& s);
void trimInPlace(std::string& s);
// Store ownership (sink): take by value (caller moves in)
class Logger {
std::string prefix_;
public:
explicit Logger(std::string prefix) : prefix_(std::move(prefix)) {}
};
// Need to pass to C API that requires null-termination
void callCLibrary(const std::string& s) {
c_function(s.c_str()); // std::string guarantees null-termination
}
Quick rule: if the function body only reads the string, use string_view. If it stores a copy, takes ownership, or modifies, use std::string.
The rule has a friction point at the boundary with APIs that still require std::string. A string_view does not convert implicitly to std::string (by design, since that would hide an allocation), so passing it on means writing std::string(sv). If a “read-only” function immediately does that — to look the key up in a std::map<std::string, T>, or to call a library that takes const std::string& — the view saved nothing. For maps, heterogeneous lookup fixes this: declare std::map<std::string, T, std::less<>> and find(sv) works without a temporary; std::unordered_map supports the same since C++20 when the hash and equality types are transparent. Also note that std::string + std::string_view has no operator+ before C++26; use s.append(sv) or std::string(a).append(b).
Lifetime Pitfalls
string_view does not own its data. If the underlying data is destroyed, the view becomes dangling — just like a dangling reference.
Dangling View from Temporary
#include <string>
#include <string_view>
std::string_view danger() {
std::string local = "hello";
return local; // WRONG — local destroyed, view dangles
}
// Even worse — the bug is hidden
std::string_view sv = std::string("hello"); // temporary destroyed immediately
// sv is dangling — undefined behavior
These bugs are hard to spot in testing for a specific reason: with a short string like "hello", SSO stores the characters inside the std::string object on the stack, and after it is destroyed the bytes are often still there, so printing sv shows the right text. Change the value to a longer string that lives on the heap, or build with AddressSanitizer, and the same code prints garbage or reports stack-use-after-scope / heap-use-after-free. Clang catches both forms at compile time with its on-by-default dangling warnings (for example “object backing the pointer will be destroyed at the end of the full-expression [-Wdangling-gsl]”), and GCC 13+ has -Wdangling-reference for some related cases; turning those warnings into errors is worthwhile in any codebase that uses views heavily.
A subtler variant involves functions returning std::string by value: std::string_view name = user.getName(); dangles if getName() returns std::string, but is fine if it returns const std::string& to a member. The call site looks identical, so a later refactor of getName()’s return type silently introduces the bug.
Dangling View from String Mutation
std::string may reallocate its buffer when it grows. Any string_view into a std::string before reallocation becomes invalid:
std::string s = "hello";
std::string_view sv = s;
s += " world"; // may reallocate — sv is now potentially dangling!
std::cout << sv << '\n'; // undefined behavior if reallocation happened
Safe Patterns
// Safe: string_view parameter — caller guarantees the string outlives the call
void process(std::string_view sv) {
// sv is valid during this function
}
// Safe: string_view from a string_literal (static lifetime)
constexpr std::string_view greeting = "Hello, World!";
// Safe: storing in a class that owns the string
class Parser {
std::string data_; // owns the buffer
std::string_view current_; // valid as long as data_ is not modified
public:
void load(std::string input) {
data_ = std::move(input);
current_ = data_; // safe — points into data_, which we own
}
};
“Safe” here comes with a condition that is easy to break: the default copy and move operations. Copying a Parser copies data_ into a new buffer but copies current_ as-is, so the copy’s view still points into the original object’s data_. Moving is worse: for a short string held in the SSO buffer, the characters stay behind in the moved-from object, and for a long string the moved-to object takes over the heap buffer — so the view might happen to stay valid or might not, depending on the length. Storing a vector of Parsers makes this concrete, since reallocation moves every element. The robust fix is to store offsets (size_t begin, len) instead of a view and build the view on demand, or to delete copy/move operations for the class.
Null-Termination Caveat
std::string guarantees null-termination — s.c_str() is always safe to pass to C APIs. string_view makes no such guarantee:
#include <cstdio>
#include <string_view>
void wrongWay(std::string_view sv) {
// WRONG — sv.data() is not null-terminated in general
printf("%s\n", sv.data());
}
void rightWay(std::string_view sv) {
// If you need null termination, convert first
std::string s(sv);
printf("%s\n", s.c_str());
// Or use the length-aware C functions
fwrite(sv.data(), 1, sv.size(), stdout);
putchar('\n');
}
String literals happen to be null-terminated, so string_view{"hello"}.data() would work with printf. But a string_view created from sv.substr(1, 3) is pointing into the middle of a buffer and is not null-terminated. Don’t rely on it.
printf also offers a length-limited form that works directly with views: printf("%.*s\n", static_cast<int>(sv.size()), sv.data());. The cast is required because the precision argument must be an int, and passing a size_t is undefined behavior on 64-bit platforms. The null-termination problem is the most frequent reason codebases end up with a mix of string_view and const std::string& parameters: functions that forward to fopen, getenv, database drivers, or OS APIs genuinely need a terminated string, and taking const std::string& there avoids a hidden copy inside the function. The standard library has no non-owning, null-terminated view type, so this split is unavoidable for now.
string_view as a Class Member
Storing string_view as a member is risky unless you carefully control the lifetime:
// RISKY — who owns the string the view points to?
class Config {
std::string_view host_; // dangerous if lifetime not guaranteed
};
// SAFE — own the string, expose view
class Config {
std::string host_;
public:
explicit Config(std::string host) : host_(std::move(host)) {}
std::string_view host() const { return host_; } // returns view — safe
};
C++20 Additions
C++20 adds a few useful string_view methods:
std::string_view s = "Hello, World!";
// starts_with and ends_with (C++20)
s.starts_with("Hello"); // true
s.ends_with("World!"); // true
// contains (C++23)
// s.contains("World"); // true — C++23 only
Rules for using string_view safely
- Use
string_viewfor read-only parameters — acceptsstd::string, string literals, andconst char*without copying string_view::substris O(1) — just adjusts pointer and length, no allocation; ideal for parsers and tokenizers- Never return
string_viewto a local — it dangles immediately when the local is destroyed - Mutation invalidates views — don’t keep a
string_viewinto astd::stringthat might be modified or grow string_viewis not null-terminated — don’t pass.data()to C APIs that expect null termination; usestd::string(sv).c_str()- Storing
string_viewin a class member requires careful lifetime ownership — often better to own astd::stringand returnstring_viewfrom an accessor
Frequently Asked Questions (FAQ)
Q. How do I parse a number from a string_view when std::stoi needs a std::string?
A. std::stoi and friends only accept std::string, and string_view does not convert to std::string implicitly, so you would have to write std::string(sv) and pay for a copy. C++17’s std::from_chars in <charconv> parses directly from a character range, std::from_chars(sv.data(), sv.data() + sv.size(), value), without allocating or requiring null termination. It returns an error code instead of throwing, so check ec in the result before using the value.
Related Articles
- std::string Pitfalls
- std::vector in Practice
- C++ Copy Initialization: The = Form, explicit, and Copy