Skip to content
C++
Library
since C++98
Basic

std::string

std::string — owning mutable character sequence with SSO, full modification API, searching, numeric conversions, and C++17/20/23 additions.

std::stringsince C++98

std::string is a specialisation of std::basic_string<char> — an owning, heap-backed, mutable sequence of char with value semantics, automatic memory management, and Small String Optimization that eliminates heap allocation for short strings.

Overview

std::string has been the standard text type since C++98. It owns its buffer, grows on demand, and provides amortised O(1) append. Any mutation that changes size or capacity invalidates all iterators and pointers into the string.

std::basic_string<CharT, Traits, Allocator> is the underlying template. Besides std::string (char), the standard provides std::u16string and std::u32string (C++11), std::wstring (C++98), and std::u8string (C++20).

For read-only string access, prefer std::string_view (C++17): it works with literals, existing buffers, and substrings without any allocation.

Construction

cpp
#include <string>

std::string s1;                         // empty — no heap touch
std::string s2 = "hello";              // copy from literal
std::string s3("hello", 3);            // "hel" — first N bytes
std::string s4(5, 'x');               // "xxxxx"
std::string s5 = s2;                   // copy
std::string s6 = std::move(s2);        // move — C++11; O(1) only for long strings (see SSO)
std::string s7(s5.begin(), s5.end());  // range constructor

// From std::string_view — C++17
std::string_view sv = "hello";
std::string s8{sv};

// User-defined literal suffix — C++14
using namespace std::string_literals;
auto s9 = "hello"s;   // std::string, not const char*

The "hello"s suffix matters whenever template argument deduction needs a concrete std::string type, and it enables "a"s + "b"s (which "a" + "b" cannot do).

Modification

cpp
std::string s = "Hello";

s += " World";           // append — amortised O(1)
s.append(" !");          // equivalent to +=
s.push_back('?');        // single char
s.insert(5, ",");        // insert at byte position

s.erase(5, 1);           // remove 1 char at pos 5
s.erase(s.begin() + 5);  // iterator overload
s.replace(0, 5, "Hi");   // replace [pos, pos+len) with new content

s.clear();               // empty string — capacity unchanged
s.resize(10);            // grows: pads with '\0'
s.resize(3);             // shrinks: truncates
s.reserve(128);          // pre-allocate — avoids reallocation in loops
s.shrink_to_fit();       // advisory release of excess capacity

reserve() is the most impactful single optimisation for string building. If you can compute the final length upfront, one reserve eliminates all reallocation.

Access and Searching

cpp
std::string s = "Hello, World!";

s.size();       // 13 — O(1)
s.empty();      // false
s.capacity();   // ≥ size(), implementation-defined

s[0];           // 'H' — no bounds check, UB on bad index
s.at(0);        // 'H' — throws std::out_of_range on bad index
s.front();      // 'H'
s.back();       // '!'

s.c_str();      // const char* — null-terminated, valid until next mutation
s.data();       // const char* (C++11); writable char* for non-const string (C++17)

find and friends

Every find variant returns a byte index of type std::string::size_type (unsigned). On a miss it returns std::string::npos, which equals size_type(-1) — the largest possible unsigned value. Never compare against a signed -1.

cpp
std::string s = "Hello, World!";

// find — first occurrence from left
s.find("World");                // 7
s.find('o');                    // 4
s.find("l", 4);                 // 10 — start search at offset 4

// rfind — first occurrence from right
s.rfind('o');                   // 8 — last 'o'
s.rfind("ll");                  // 2

// find_first_of — first char that appears in the set
s.find_first_of("aeiouAEIOU"); // 1 — 'e'

// find_last_of — last char that appears in the set
s.find_last_of("aeiouAEIOU");  // 8 — 'o'

// find_first_not_of — first char NOT in the set
s.find_first_not_of("Helo, W"); // 9 — 'r'

// find_last_not_of — last char NOT in the set
s.find_last_not_of("! ");       // 11 — 'd'

// Idiomatic sentinel check
auto pos = s.find("missing");
if (pos != std::string::npos) {
    // found at pos
}

C++20 and C++23 added predicate-style membership tests that are far more readable than find() != npos for simple yes/no questions:

cpp
s.starts_with("Hello");  // true  — C++20
s.ends_with("!");        // true  — C++20
s.contains("World");     // true  — C++23

substr

cpp
std::string s = "Hello, World!";

s.substr(7);       // "World!" — allocates a new std::string
s.substr(7, 5);    // "World" — pos, length

// Slice without allocation — C++17
std::string_view sv{s};
sv.substr(7, 5);   // returns a string_view, zero copy

Common pitfall. substr in a hot loop creates a heap allocation per call. Use std::string_view::substr for intermediate slicing; only materialise a std::string when ownership is actually required.

Small String Optimization (SSO) Internals

The std::string object itself is 24 or 32 bytes depending on the ABI. Implementations repurpose those bytes as an inline character buffer for short strings, bypassing the heap entirely.

ImplementationInline capacity (null excluded)sizeof(std::string)
libstdc++ (GCC)15 chars32 bytes
libc++ (Clang)22 chars (64-bit)24 bytes
MSVC STL15 chars32 bytes

You can observe the SSO buffer size at runtime: a default-constructed std::string has no heap allocation, so its capacity() equals the inline buffer minus the null terminator.

cpp
#include <string>
#include <iostream>

std::string s;
std::cout << sizeof(s)     << '\n';  // 24 or 32
std::cout << s.capacity()  << '\n';  // 15 or 22 — SSO inline buffer

// Short string: lives entirely inside the object
std::string a = "Hi!";              // 3 chars — fits inline, no malloc
std::string b = std::move(a);       // must copy bytes — NOT O(1)
// 'a' is valid-but-unspecified after the move; do not rely on it being empty

// Long string: heap-allocated buffer with a pointer inside the object
std::string c = "This string is long enough to require a heap allocation";
std::string d = std::move(c);       // O(1) — steals the pointer, no copy
// 'c' is guaranteed empty after moving a heap string (pointer zeroed)

Best Practice. Avoid the assumption that std::move on any std::string is free. For strings shorter than ~15–22 characters, the move constructor copies bytes the same as a copy constructor. Profile before relying on move performance in tight loops.

Moved-from state

The standard only guarantees that a moved-from std::string is in a "valid but unspecified" state. You may re-assign or destroy it, but you must not inspect its contents. For short strings, many implementations leave a with a copy of the original data after b = std::move(a), because no ownership transfer is needed. This is legal and correct, but surprising.

cpp
std::string small = "abc";      // SSO — inline
std::string taken = std::move(small);
// small.empty() is NOT guaranteed to be true here
// but small is still safe to destroy or reassign
small = "new value";            // always fine

Capacity Management

std::string uses the same exponential growth strategy as std::vector: when the buffer is full, most implementations approximately double it. This keeps amortised-O(1) append, but causes O(log N) reallocations when building an N-character string one character at a time.

cpp
// Anti-pattern: 1,000,000 push_backs with no reserve
// triggers ~20 reallocations (log₂(1,000,000) ≈ 20)
std::string bad;
for (int i = 0; i < 1'000'000; ++i)
    bad.push_back('x');

// Correct pattern: one reserve, zero reallocations
std::string good;
good.reserve(1'000'000);
for (int i = 0; i < 1'000'000; ++i)
    good.push_back('x');

The capacity never shrinks automatically. To release excess memory use shrink_to_fit(), which is an advisory (non-binding) request:

cpp
std::string s(10'000, 'x');
s.clear();             // size == 0, capacity still ~10000
s.shrink_to_fit();     // requests capacity ≈ 0; implementation may ignore it

// Guaranteed shrink idiom (swap with a temporary)
std::string().swap(s); // capacity is now 0

Best Practice. Call reserve() before any loop that appends to a string when you can estimate the final size. Even a rough upper bound is better than nothing, because it prevents the most expensive doubling steps.

Number Conversions

Locale-sensitive helpers (C++11)

cpp
#include <string>

// Numeric → string (always allocates)
std::string s1 = std::to_string(42);       // "42"
std::string s2 = std::to_string(3.14);     // "3.140000"

// String → numeric (throw std::invalid_argument or std::out_of_range on failure)
int    i   = std::stoi("42");
long   l   = std::stol("1234567");
double d   = std::stod("2.718281828");
int    hex = std::stoi("FF", nullptr, 16); // 255

These functions respect the global locale. On a system where the decimal separator is a comma (e.g., many European locales), std::stod("3.14") may throw or parse incorrectly.

Locale-independent, allocation-free: <charconv> (C++17)

cpp
#include <charconv>  // C++17

// from_chars: no exceptions, no allocation, no locale
int n{};
std::string src = "42";
auto [ptr, ec] = std::from_chars(src.data(), src.data() + src.size(), n);
if (ec == std::errc{}) {
    // n == 42; ptr points one past the last digit consumed
}

// to_chars: writes into a caller-supplied buffer, returns a pointer to the end
char buf[32];
auto [end, ec2] = std::to_chars(buf, buf + sizeof(buf), 12345);
std::string_view result{buf, end};  // no heap involved

from_chars/to_chars are the correct tools for performance-sensitive code: locale-independent, exception-free, and roughly 10× faster than stoi/to_string in benchmarks. They also compose cleanly with std::string_view because they work on raw const char* ranges.

String Concatenation Pitfalls

"a" + "b" does not compile

Raw string literals have type const char[N]. The built-in + operator does not exist for arrays; what the compiler sees is an attempt to do pointer arithmetic, which is a compile error (or, with character arrays, UB). You must involve at least one std::string:

cpp
// Compile error — both operands are const char*
// auto bad = "Hello" + ", World!";

// OK — left operand is std::string
std::string good = std::string("Hello") + ", World!";

// OK — user-defined literal produces std::string — C++14
using namespace std::string_literals;
auto good2 = "Hello"s + ", World!";

operator+ chains allocate repeatedly

Every call to operator+ creates a temporary std::string with its own allocation. A chain of four + operations creates three temporaries:

cpp
// 3 temporary strings and up to 3 heap allocations
std::string result = a + ", " + b + "!";

// 1 allocation, 4 appends — preferred for performance
std::string result;
result.reserve(a.size() + 2 + b.size() + 1);
result += a;
result += ", ";
result += b;
result += '!';

std::ostringstream for heterogeneous building

When building a string from mixed types (ints, doubles, custom types with operator<<), std::ostringstream is convenient even if slightly slower than direct reserve+append:

cpp
#include <sstream>

std::ostringstream oss;
oss << "Error at line " << line << ": " << message;
std::string result = oss.str();

std::format for structured output (C++20)

cpp
#include <format>

// Single allocation; type-safe; cleaner than chained operator+  — C++20
std::string msg = std::format("{}:{} — {}", file, line, reason);

// std::format_to avoids even the final allocation by writing directly to a range
std::string buf;
buf.reserve(64);
std::format_to(std::back_inserter(buf), "{:>10}", value);  // C++20

Best Practice. Prefer std::format for any multi-part string construction in C++20 and later. It is clearer than operator+ chains and avoids ostringstream's setup overhead.

Iterator Invalidation

std::string follows the same invalidation rules as std::vector: any operation that causes reallocation invalidates every outstanding iterator, pointer, and reference into the string.

OperationInvalidates
push_back / append without reallocationOnly end() iterator
push_back / append with reallocationAll iterators, pointers, references
insert without reallocationAll at or after the insertion point
insert with reallocationAll
eraseAll at or after the erased position
replaceAll (may reallocate)
reserve with reallocationAll
clearAll
resize growingSame as push_back
resize shrinkingAll at or after new end()
operator= / move assignmentAll

Bug pattern: iterator captured before mutation

cpp
// BUG: iterator captured before append that may reallocate
std::string s = "Hello";
auto it = s.begin();   // iterator into s

s += ", World!";       // may reallocate — 'it' is now dangling

// Dereferencing 'it' here is undefined behaviour
// std::cout << *it;   // <-- UB

Fix: use index offsets, not iterators, across mutations

cpp
// CORRECT: store a byte offset, recompute pointer after mutation
std::string s = "Hello";
std::size_t pos = 0;   // index into s, not an iterator

s += ", World!";       // may reallocate — 'pos' is still valid

char c = s[pos];       // safe: 'H'

Alternatively, complete all mutations before retaining any iterator:

cpp
// CORRECT: all mutations first, then iterate
std::string s = "Hello";
s += ", World!";
s += "!";

for (auto it = s.begin(); it != s.end(); ++it) {
    // no mutations here — iterators stable
}

Best Practice. Never hold iterators, pointers (c_str() / data()), or references across any call that might modify the string. If you need to retain a position across a mutation, store the integer index instead.

Best Practices

Pre-reserve before accumulation loops:

cpp
std::string result;
result.reserve(estimated_total);
for (const auto& chunk : chunks) {
    result += chunk;
    result += '\n';
}

Accept read-only strings as string_view:

cpp
// Accepts literals, std::string, std::string_view, buffers — zero copy
void log(std::string_view msg);

// Forces an allocation when caller passes a literal
void log(const std::string& msg);  // avoid for read-only params

Use std::format (C++20) for multi-part construction:

cpp
#include <format>
std::string msg = std::format("{}:{} — {}", file, line, reason);  // C++20
// Single allocation; cleaner than chained operator+

Use from_chars/to_chars in hot paths instead of stoi/to_string to eliminate locale overhead and allocations.

Use contains / starts_with / ends_with instead of find() != npos:

cpp
// Verbose and easy to get wrong
if (s.find("prefix") == 0) { /* ... */ }

// Clear and idiomatic — C++20
if (s.starts_with("prefix")) { /* ... */ }

// C++23
if (s.contains("needle")) { /* ... */ }

Common Pitfalls

c_str() and data() pointers go stale on mutation:

cpp
const char* p = s.c_str();
s += " more";   // may reallocate — p is now dangling, UB to dereference

operator+ chains allocate repeatedly:

cpp
// 3 temporary strings and 3 allocations
std::string result = a + ", " + b + "!";

// 1 allocation, 4 appends
std::string result;
result.reserve(a.size() + 2 + b.size() + 1);
result += a; result += ", "; result += b; result += '!';

substr in hot loops creates heap allocations: use std::string_view::substr for intermediate slicing, then construct a std::string only when ownership is required.

Moving a short string is not O(1): SSO strings carry their data inline; the move constructor must copy bytes. Profile before assuming move is free.

Comparison with std::string::npos: find returns std::string::size_type (unsigned). Testing against != -1 silently converts -1 to npos on most platforms but is technically implementation-defined; always write != std::string::npos.

"a" + "b" is a compile error: raw string literals are const char*; there is no operator+ for two pointers. Use "a"s + "b"s (C++14) or explicit std::string construction.

See Also