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

Propertystd::stringstd::string_view
OwnershipOwns the bufferNon-owning
AllocationHeap (unless SSO)None
Construction from literalO(n) copyO(1) pointer+length
MutabilityMutableRead-only
Null-terminatedYes (.c_str())Not guaranteed
Size~24-32 bytes (+ heap)16 bytes (2 words)
substr()New allocationO(1) — returns view
Safe to storeAlwaysOnly if source outlives
Available sinceC++98C++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_view for read-only parameters — accepts std::string, string literals, and const char* without copying
  • string_view::substr is O(1) — just adjusts pointer and length, no allocation; ideal for parsers and tokenizers
  • Never return string_view to a local — it dangles immediately when the local is destroyed
  • Mutation invalidates views — don’t keep a string_view into a std::string that might be modified or grow
  • string_view is not null-terminated — don’t pass .data() to C APIs that expect null termination; use std::string(sv).c_str()
  • Storing string_view in a class member requires careful lifetime ownership — often better to own a std::string and return string_view from 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.