std::string in C++ — The Complete Guide (with string_view and std::format)

Fifteen characters cost nothing; sixteen cost a heap allocation. A measured guide to std::string, string_view, and where the copies come from.

A green frame floating over part of a long row of blue blocks, illustrating a C++ string_view borrowing part of a string

A std::string holding 15 characters costs zero heap allocations. Add one more character and it costs one. That cliff — measured, not assumed — is the difference between a string type that is effectively free and one that touches the allocator on every construction, and it explains most of what people find surprising about C++ string performance.

This guide covers std::string from declaration through to the C++20 and C++23 additions most tutorials still omit: string_view, std::format, starts_with, and contains. Every program below was compiled and run for this article on Ubuntu 24.04 with GCC 13.3 under -std=c++20 (and -std=c++23 where noted), with -Wall -Wextra. Allocation counts come from a replaced operator new rather than estimation, the dangling-string_view corruption is real output from a real run, and the compiler diagnostics are quoted verbatim from GCC and Clang 18.

Table of Contents

What Is std::string in C++?

std::string is the C++ standard library’s owning, resizable string type, declared in <string>. It manages its own memory, grows automatically as you append, and cleans up when it goes out of scope, which removes the manual buffer arithmetic and null-terminator handling that C-style char arrays require. It is technically a typedef for std::basic_string<char>, and supports comparison, concatenation, searching, and substring extraction as member functions and operators.

The practical difference from a char array is ownership. A std::string knows its own length, can reallocate when it outgrows its buffer, and copies safely on assignment. A char* knows none of those things, which is why the migration from C strings eliminates an entire category of bug — described in the guide to comparing strings in C, where == compares addresses rather than contents.

For the complete member list and the exact guarantees each function makes, cppreference’s std::basic_string page is the definitive reference.

Creating and Initializing Strings

cpp

#include <iostream>
#include <string>

int main() {
    std::string empty;                          // ""
    std::string greeting = "Hello, world";      // from a literal
    std::string copy(greeting);                 // copy construction
    std::string repeated(5, 'x');               // "xxxxx"
    std::string part(greeting, 7, 5);           // "world" - from index 7, 5 chars
    std::string fromIter(greeting.begin(), greeting.begin() + 5);  // "Hello"

    std::cout << part << " / " << repeated << " / " << fromIter << '\n';
}

Two habits worth adopting early. Prefer std::string explicitly over using namespace std; — the std:: prefix costs five characters and avoids the name collisions that make large codebases painful. And note that std::string may contain embedded null characters; it is not defined by a terminator the way a C string is, which is why size() is O(1) rather than a scan.

Small String Optimization: The 15-Character Cliff

Here is the property that governs std::string performance and that almost no introductory guide mentions.

Every std::string object contains a small internal buffer. If your text fits in it, the characters live inside the object itself — on the stack, with no allocator involvement at all. Only when the text outgrows that buffer does the string reach for the heap.

The threshold is measurable. Replacing operator new to count allocations:

sizeof(std::string) = 32 bytes
empty string capacity = 15

len  allocs  capacity  storage
   1     0        15      stack (SSO)
   8     0        15      stack (SSO)
  15     0        15      stack (SSO)
  16     1        16      HEAP
  17     1        17      HEAP
  64     1        64      HEAP

Fifteen characters, then a cliff. Below it, constructing a string is essentially free. At sixteen, every construction is a malloc and every destruction a free.

Small String Optimization:the 15-character cliffMeasured by counting heap allocations. libstdc++, x86-64.15 chars or fewer — 0 allocationsthe string object (32 bytes, on the stack)characters live inside the object itself16 chars or more — 1 allocationobject (stack)ptrlencapheap bufferMeasuredlengthallocations1 – 15016 and above1Short strings cost nothing to create.Crossing 15 characters turns a free operationinto a heap allocation. This is why passingstring_view avoids a malloc that std::string does not.The limit is implementation-defined; 15 is libstdc++ and libc++.

Two caveats. The limit is implementation-defined: 15 is what libstdc++ and libc++ use on 64-bit platforms, MSVC also uses 15, but nothing in the standard guarantees it. And SSO is why sizeof(std::string) is 32 bytes rather than the 24 you might expect from a pointer, a length and a capacity — the extra space is the buffer.

The practical consequence: short strings are cheap enough to stop worrying about, and the moment your strings routinely exceed 15 characters, allocation behaviour starts to matter. Which is where the next section comes in.

std::string_view (C++17): Borrowing Instead of Copying

A std::string_view is a non-owning window onto character data: a pointer and a length, nothing more. It does not allocate, does not copy, and does not own what it points at.

The difference is measurable:

take_string(const char*)  -> 1 allocation(s)
take_view  (const char*)  -> 0 allocation(s)

big.substr(10,100)              -> 1 allocation(s)
string_view(big).substr(10,100) -> 0 allocation(s)
  (both give 100 chars)

sizeof(std::string)      = 32 bytes
sizeof(std::string_view) = 16 bytes

Passing a string literal to a function taking const std::string& constructs a temporary std::string — one allocation, every call. Taking std::string_view instead costs nothing.

The rule: if a function only reads a string and does not store it, take std::string_view.

// Before: allocates when called with a literal or a char*
void log(const std::string& message);

// After: no allocation, and still accepts std::string, char*, and literals
void log(std::string_view message);

The dangling string_view trap

Because a view does not own its data, it becomes invalid the moment the underlying data dies. This compiles cleanly and is a use-after-free:

std::string make_name() { return "a temporary string long enough to heap-allocate"; }

std::string_view bad = make_name();   // temporary destroyed at the semicolon
std::cout << bad << '\n';             // reads freed memory

Actual output from that program:

expected: a temporary string long enough to heap-allocate
actual  : [M-EM-zGV^E^@^@^@O1M-G^#rjang long enough to heap-allocate]
size    : 47

The first fourteen bytes have been overwritten by the allocator’s own bookkeeping; the tail survives because nothing has reused it yet. The string reports the right length while returning wrong data — the kind of corruption that passes a smoke test and fails in production.

Which tools catch it? Tested on the same file:

ToolResult
GCC 13.3 -Wall -Wextrano warning
GCC 13.3 -Wdangling-referenceno warning
Clang 18 -Wall -Wextrawarns: object backing the pointer will be destroyed at the end of the full-expression [-Wdangling-gsl]

If you use string_view widely, that is a concrete argument for running Clang over your code even if you ship with GCC.

The fix is to own the data whenever its lifetime must outlive the expression:

std::string keep = make_name();   // owns it
std::string_view good = keep;     // safe while keep is alive

Never store a string_view as a class member unless you control the lifetime of what it points at, and never return one that refers to a local.

Common Operations and Their Complexity

OperationExampleComplexity
Lengths.size(), s.length()O(1)
Indexs[i], s.at(i)O(1)
Append chars += 'x', s.push_back('x')Amortised O(1)
Append strings += t, s.append(t)O(len of t), amortised
Inserts.insert(pos, t)O(size + len of t)
Erases.erase(pos, n)O(size)
Replaces.replace(pos, n, t)O(size + len of t)
Find substrings.find(t)O(size × len of t) worst case
Substrings.substr(pos, n)O(n) — allocates
Compares == t, s.compare(t)O(min size)
Reserves.reserve(n)O(n), at most one allocation
Clears.clear()O(1) — keeps capacity

Two entries deserve attention. substr() allocates — it returns a new std::string. When you only need to read the slice, std::string_view::substr() gives you the same characters for free, as measured above.

And reserve() collapses repeated reallocation:

Building a 10000-char string with +=
  without reserve() : 10 allocations
  with reserve()    : 1 allocation(s)

Ten allocations become one. Whenever you know the approximate final size, say so up front.

at() versus []

s.at(i) performs bounds checking and throws std::out_of_range; s[i] does not check, and an out-of-range index is undefined behaviour. Use at() when the index comes from outside your control, and [] in loops where you have already established the bound.

Searching, Modifying, and the npos Convention

std::string s = "the quick brown fox";

size_t pos = s.find("brown");                 // 10
if (pos != std::string::npos)
    s.replace(pos, 5, "red");                 // "the quick red fox"

size_t none = s.find("purple");               // std::string::npos

find() returns std::string::npos when there is no match — always check before using the result, because passing npos to replace() or substr() is a bug rather than a no-op.

A correction worth stating explicitly, because the claim circulates widely: replace() does not search and cannot return npos. It returns a reference to the modified string. Verified with the type system:

std::string::replace returns std::string& : true
  (it does not search, and cannot return npos)
  find() is the function that returns npos: confirmed

The find family covers most needs: find, rfind (last occurrence), find_first_of / find_last_of (any character from a set), and find_first_not_of / find_last_not_of (the complement, useful for trimming whitespace).

Modern Additions: C++20 and C++23

The old advice to write helper functions for prefix and suffix checks is obsolete:

std::string path = "config/settings.json";

path.starts_with("config/");   // true   (C++20)
path.ends_with(".json");       // true   (C++20)
path.contains("settings");     // true   (C++23)
starts_with("config/") : true
ends_with(".json")     : true
contains("settings")   : true  (C++23)

All three exist on std::string_view as well.

std::format (C++20)

std::format replaces both printf-style formatting (unsafe, no type checking) and ostringstream chains (verbose, awkward):

#include <format>

std::string name = "Ada";
int score = 97;

std::string out = std::format("{} scored {}%  ({:.1f} avg)\n", name, score, 88.25);
std::cout << std::format("[{:>10}] [{:<10}] [{:^10}]\n", "right", "left", "mid");
Ada scored 97%  (88.2 avg)
[     right] [left      ] [   mid    ]

It is type-safe — a mismatch is a compile error rather than undefined behaviour — and the alignment specifiers (>, <, ^) handle padding that would otherwise need manual loops. Available in GCC 13+, Clang 17+, and MSVC 19.29+. Where <format> is unavailable, the fmt library provides the same interface.

Conversions

int i        = std::stoi("42");
long l       = std::stol("1234567890");
double d     = std::stod("3.14159");
std::string s = std::to_string(84);
stoi("42")      = 42
stod("3.14159") = 3.14159
to_string(84)   = 84

The sto* functions throw std::invalid_argument if no conversion is possible and std::out_of_range if the value does not fit — so wrap them in try/catch when parsing untrusted input, or use std::from_chars (C++17) which reports failure through a return value instead of an exception and does not allocate.

Interop: std::string ↔ const char* ↔ std::string_view

From → ToHowCost
std::stringconst char*s.c_str()Free — but see the lifetime warning below
const char*std::stringstd::string s(p);Allocates and copies
std::stringstd::string_viewimplicitFree
std::string_viewstd::stringstd::string s(sv);Allocates and copies
std::string_viewconst char*not directlysv.data() is not null-terminated

Two hazards live in that table.

c_str() returns a pointer into the string’s own buffer. It is valid only until the string is modified or destroyed. Storing it and using it later is a use-after-free:

const char* p;
{
    std::string tmp = "hello";
    p = tmp.c_str();
}
// tmp is gone; p dangles

string_view::data() is not guaranteed to be null-terminated, because a view can point at the middle of a larger buffer. Passing it to a C API that expects a null-terminated string reads past the end. Construct a std::string first when you need to cross into C.

Common Pitfalls

  • Returning a string_view to a local or temporary. Demonstrated above — the data dies, the view does not know. GCC does not warn; Clang does.
  • Taking const std::string& for read-only parameters. Costs an allocation on every call from a literal or char*. Take std::string_view instead.
  • Using substr() to inspect a slice. Allocates a copy for data you are about to discard. string_view::substr() is free.
  • Forgetting to check find() against npos. npos is the largest size_t, so using it as an index is not a small error.
  • Comparing with strcmp out of habit. std::string overloads ==, <, and (since C++20) <=>. strcmp requires c_str() and buys nothing.
  • Appending in a loop without reserve(). Ten allocations instead of one, as measured above.
  • Assuming size() counts characters in UTF-8 text. It counts bytes. A string containing “é” has size() == 2. std::string is byte-oriented and has no notion of Unicode code points.

Key Takeaways

  • Strings of 15 characters or fewer allocate nothing — measured by counting operator new calls. At 16, every construction is a heap allocation. The limit is implementation-defined but 15 across libstdc++, libc++ and MSVC.
  • Take std::string_view for read-only parameters. Passing a literal to const std::string& cost one allocation per call; string_view cost zero.
  • string_view does not own its data. A view onto a temporary produced visibly corrupted output, and GCC issued no warning while Clang did.
  • substr() allocates; string_view::substr() does not.
  • reserve() turned 10 allocations into 1 when building a 10,000-character string.
  • replace() returns std::string&, not npos — it does not search. find() is the function that returns npos.
  • C++20 and C++23 removed the need for helper functions: starts_with, ends_with, contains, and std::format.

Frequently Asked Questions

Conclusion

Most of what makes std::string confusing is invisible in the syntax. Assignment looks the same whether it allocates or not; substr looks the same as a view; a dangling string_view looks exactly like a valid one right up until it prints garbage. The syntax hides the memory, which is convenient until performance or lifetime becomes the question.

The useful mental model is ownership. std::string owns and therefore may allocate; string_view borrows and therefore may dangle. Once that distinction is automatic, the rest of the API follows — you reach for a view when reading, a string when keeping, and reserve() when you already know the size. Those same ownership questions run through the rest of modern C++, from std::vector to smart pointers, and are worth getting right here where the cost of being wrong is smallest.

Scroll to Top