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

std::string

"The C++ standard string class — mutable, owning character sequences with construction, modification, searching, and conversions. Zero-copy alternatives: std::string_view."

std::string (C++98)since C++98

A mutable, dynamically-allocated, owning sequence of char that manages its own memory, supporting concatenation, substring search, element access, and conversion to/from numeric types and C strings.

Overview

std::string is a container that abstracts away the complexity of managing C-style null-terminated character buffers. Unlike const char* (which is just a pointer), a std::string owns the memory it references and can grow or shrink on demand.

Most implementations use small string optimization (SSO): strings up to ~15–23 bytes are stored inline in the object itself, avoiding heap allocation. Longer strings allocate on the heap. This means small strings have no allocation overhead, and moves of small strings remain cheap.

For read-only access to string data without ownership transfer, use std::string_view (C++17). For string building with complex formatting, prefer std::format() (C++20) over std::ostringstream or std::to_string().


Syntax

Construction

cpp
#include <string>

std::string s1;                        // empty
std::string s2 = "hello";              // from literal
std::string s3("hello", 3);            // "hel" — first 3 chars
std::string s4(5, 'x');                // "xxxxx"
std::string s5 = s2;                   // copy constructor
std::string s6 = std::move(s2);        // move constructor (s2 is valid, unspecified)

// From iterators (C++98)
std::vector<char> vec = {'a', 'b', 'c'};
std::string s7(vec.begin(), vec.end());

// C++11 user-defined literal
using namespace std::string_literals;
auto s8 = "hello"s;                    // std::string, not const char*
auto s9 = R"(raw\nliteral)"s;          // raw string literal as std::string (C++11)

Modification

cpp
std::string s = "hello";

s += " world";                         // append (operator+=)
s.append(" more");                     // append substring
s.append(3, '!');                      // append 3 '!' chars (C++98)
s.push_back('?');                      // append single char
s.insert(5, " there");                 // insert at position
s.erase(10);                           // erase from position 10 to end
s.erase(5, 6);                         // erase 6 chars starting at position 5
s.replace(0, 5, "bye");                // replace range [0,5) with "bye"
s.clear();                             // empty
s.resize(20);                          // resize to 20 (pad with '\0' or truncate)
s.resize(20, '-');                     // resize with fill char
s.reserve(100);                        // pre-allocate capacity (does not change size)
s.shrink_to_fit();                     // release excess capacity (C++11)

Access and slicing

cpp
std::string s = "hello";

s[0];                    // 'h' — unchecked access
s.at(0);                 // 'h' — throws std::out_of_range if out of bounds
s.front();               // 'h' (since C++11, undefined if empty)
s.back();                // 'o' (since C++11, undefined if empty)

const char* cstr = s.c_str();          // null-terminated const char* (always valid)
const char* data = s.data();           // const char* (C++11: guaranteed null-terminated)
char* mutable_data = s.data();         // non-const (C++17: safe to write before size())

s.size();                // 5 — number of chars
s.length();              // 5 — same as size()
s.empty();               // false
s.capacity();            // allocated storage

std::string_view sv = s;               // (C++17) zero-copy view; does not own
sv.substr(1, 3);                       // "ell" — zero-copy substring (C++20)

std::string copy = s.substr(1, 3);     // "ell" — creates new string copy

Search and matching

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

size_t pos = s.find("quick");          // 4 — position of first match
if (pos != std::string::npos) { /* found */ }

s.rfind("the");                        // 0 — position of last match
s.find_first_of("aeiou");              // 2 — first vowel
s.find_last_of("aeiou");               // 17 — last vowel
s.find_first_not_of(" ");              // first non-space

// C++20 and later
s.starts_with("the");                  // true (C++20)
s.ends_with("fox");                    // true (C++20)
s.contains("brown");                   // true (C++23)

Conversion

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

// Number → string (C++11)
std::string s1 = std::to_string(42);
std::string s2 = std::to_string(3.14159);

// Better: std::format (C++20, preferred)
#include <format>
std::string s3 = std::format("{:.2f}", 3.14159);  // "3.14"
std::string s4 = std::format("{:04x}", 255);      // "00ff"

// String → number (C++11)
int n = std::stoi("42");               // throws std::invalid_argument if not a number
long l = std::stol("123456789");
double d = std::stod("3.14");

// Faster, non-throwing (C++17)
int result;
auto [ptr, ec] = std::from_chars(s.data(), s.data() + s.size(), result);
if (ec == std::errc()) { /* success */ }

// To uppercase/lowercase (C++23 range algorithms, ASCII only)
#include <algorithm>
std::ranges::transform(s, s.begin(), [](unsigned char c) { return std::tolower(c); });

Best Practices

Prefer std::string_view for read-only parameters (C++17).
Pass std::string_view s instead of std::string s or const std::string& s to avoid unnecessary copies. Exception: if the function stores the string or calls std::move(), take by const std::string& or smart pointer.

cpp
// Good
void process(std::string_view line);

// Avoid
void process(std::string line);  // copies the entire string
void process(const std::string& line);  // binds to temporaries, not movable

Use std::format() for complex formatting (C++20).
It is faster and more readable than std::ostringstream.

cpp
// C++20
auto msg = std::format("id={:04d}, value={:.2f}", id, value);

// Older
std::ostringstream oss;
oss << "id=" << std::setfill('0') << std::setw(4) << id 
    << ", value=" << std::fixed << std::setprecision(2) << value;
auto msg = oss.str();

Move when building. If constructing a std::string in a loop or from a temporary, move it to avoid copies.

cpp
std::vector<std::string> lines;
for (...) {
    std::string line = read_line();
    lines.push_back(std::move(line));  // move, don't copy
}

Reserve capacity upfront if you know the final size. This avoids repeated allocations.

cpp
std::string result;
result.reserve(total_size);  // allocate once
for (auto& item : items) {
    result += format_item(item);
}

Common Pitfalls

Forgetting std::string::npos.
find() returns std::string::npos (not -1 or SIZE_MAX) when not found. Comparing against an integer is a silent bug:

cpp
// Wrong
if (s.find("x") >= 0) { /* always true, npos is SIZE_MAX */ }

// Correct
if (s.find("x") != std::string::npos) { /* found */ }

Data pointers become invalid after reallocation.
Calls like append(), insert(), or erase() may trigger a reallocation, invalidating all pointers and iterators. Store the substring you need before modifying:

cpp
// Fragile
const char* data = s.data();
s.append("more");  // may reallocate; data now invalid
std::cout << data;  // undefined behavior

// Safe
std::string copy(s);
s.append("more");
// copy remains valid

SSO means small moves don't reallocate.
Moving a small string (≤ SSO buffer) may not move the underlying data; it copies in-place. Large strings do move. Don't assume move() always avoids allocation.

Null-termination is guaranteed, but data() is mutable (C++17).
You can write to s.data() up to s.size() without the null terminator. The null terminator (at position s.size()) is written only on data() or c_str() calls; modifying it directly may break invariants. Use reserve() and explicit null-termination if working with C APIs that modify the buffer.

operator[] has no bounds checking.
Use at() for bounds-checked access that throws std::out_of_range.

cpp
s[1000];      // undefined if s.size() < 1000
s.at(1000);   // throws std::out_of_range

Small String Optimization (SSO)

SSO stores short strings inside the std::string object itself, avoiding heap allocation entirely. The threshold is implementation-defined — typically 15 bytes (libstdc++, MSVC) to 22 bytes (libc++) — but sizeof(std::string) is always 32 bytes on 64-bit platforms regardless. Any string fitting within the threshold lives on the stack.

cpp
#include <string>
#include <cstdio>

int main() {
    // sizeof is always the same — SSO storage is embedded
    static_assert(sizeof(std::string) == 32);  // true on most 64-bit platforms

    std::string short_s = "hi";          // 2 bytes — lives entirely in the object (no heap)
    std::string long_s(40, 'x');         // 40 bytes — heap allocated

    // You can observe SSO by comparing the address of .data() to the object address
    auto obj_addr = reinterpret_cast<const char*>(&short_s);
    bool is_sso = (short_s.data() >= obj_addr &&
                   short_s.data() <  obj_addr + sizeof(short_s));
    std::printf("short SSO: %s\n", is_sso ? "yes" : "no");  // yes
}

Moving an SSO string does not move a pointer — it copies the inline bytes. This means a pointer into the data of a moved-from SSO string becomes a dangling pointer.

cpp
std::string a = "short";          // SSO — data lives inside `a`
const char* raw = a.data();       // raw points into `a`'s inline buffer
std::string b = std::move(a);     // copies inline bytes into `b`; `a` is emptied
// raw still points into `a`, which may have been overwritten — undefined behavior
std::printf("%s\n", raw);         // UB!

In tight loops, SSO makes small-string operations significantly cheaper. Avoid prematurely converting to const char* and re-wrapping; stay in std::string or std::string_view to keep SSO benefits.


String Building and Concatenation Performance

s1 + s2 + s3 chains create a temporary string for each +, triggering up to N-1 extra allocations. For two or three strings this is negligible; for ten or more it adds up.

cpp
// Avoid for many pieces: creates "s1+s2" then "(s1+s2)+s3" — two heap allocs
std::string result = s1 + s2 + s3;

// Better: operator+= modifies in-place (one allocation if capacity is exhausted)
std::string result;
result += s1;
result += s2;
result += s3;

When the final size is known in advance, reserve eliminates all reallocations.

cpp
std::string result;
result.reserve(s1.size() + s2.size() + s3.size());  // single allocation
result += s1;
result += s2;
result += s3;

std::ostringstream is convenient for mixed-type formatting but carries locale and stream state overhead, making it notably slower than direct += or reserve++=.

cpp
// Readable but slower due to stream overhead
std::ostringstream oss;
oss << "value=" << 42 << ", flag=" << true;
std::string s = oss.str();

For mixed-type formatting in modern code, prefer std::format — it is faster than ostringstream, fully type-safe, and locale-independent by default.

cpp
// C++20
#include <format>
std::string s = std::format("value={}, flag={}", 42, true);  // C++20 — fast and readable

Benchmark guidance: profile before optimizing. For under ~8 concatenations with short strings, + operator chains are fine. Beyond that, use reserve + += or std::format.


Searching: find, rfind, find_first_of, contains

find returns std::string::npos on failure — always compare against npos, never against -1 or 0. The optional second argument is the starting position for the search.

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

// find / rfind with optional start position
size_t first = s.find("the");            // 0
size_t second = s.find("the", 1);        // npos — no second "the"
size_t last = s.rfind("o");             // 17 — last 'o' (in "fox")

// Extract the substring after the first space
size_t sp = s.find(' ');
if (sp != std::string::npos) {
    std::string rest = s.substr(sp + 1); // "quick brown fox jumps"
}

find_first_of / find_last_of search for any character in a set, useful for parsing delimiters.

cpp
std::string csv = "alpha,beta;gamma|delta";
size_t delim = csv.find_first_of(",;|");   // 5 — first delimiter character
size_t last_d = csv.find_last_of(",;|");   // 16 — last delimiter character

C++20 added starts_with and ends_with; C++23 added contains, all of which read more clearly than the equivalent find checks.

cpp
// C++20
s.starts_with("the");                     // true  // C++20
s.ends_with("jumps");                     // true  // C++20

// C++23
s.contains("brown");                      // true  // C++23
s.contains("zebra");                      // false // C++23

std::string and UTF-8

std::string is byte-transparent: it stores UTF-8 perfectly but size() returns the number of bytes, not the number of Unicode characters. A UTF-8 encoded string with 5 emoji may have size() == 20.

cpp
#include <string>

// u8 prefix guarantees UTF-8 encoding (C++20: type is std::u8string; C++11-17: const char*)
std::string greeting = u8"héllo";    // 'é' encodes to 2 bytes in UTF-8
std::printf("size=%zu\n", greeting.size());   // 6, not 5

length() is an alias for size() — it also returns bytes, not characters.

cpp
std::string emoji = u8"\U0001F600";   // one emoji, 4 UTF-8 bytes
greeting.size();    // 4
greeting.length();  // 4 — same: bytes, not code points

std::string_view is equally byte-transparent; creating a string_view from a std::string preserves UTF-8 content without copying but carries the same byte-count semantics. substr on a multi-byte UTF-8 string can split a code point if the offset falls in the middle of a multi-byte sequence — the resulting string contains invalid UTF-8.

cpp
std::string s = u8"héllo";           // h=1 e=1 é=2 l=1 l=1 o=1 → 7 bytes
std::string broken = s.substr(0, 2); // byte 0 and 1: 'h' and first byte of 'é' — invalid UTF-8

For Unicode-aware operations (character count, case folding, collation), use a dedicated library such as ICU or utfcpp; the standard library has no UTF-8-aware string algorithms.


Converting to and from Numeric Types

std::stoi / std::stod are convenient but throw on bad input and are locale-dependent — they can parse "1,000" as 1 in some locales. For robust, high-performance parsing use std::from_chars (C++17), which is non-throwing and locale-independent.

cpp
// C++11 — throwing, locale-dependent
int a = std::stoi("42");             // 42
int b = std::stoi("  42px");         // 42 — stops at 'p'
int c = std::stoi("abc");            // throws std::invalid_argument

std::from_chars returns a result struct; check ec for errors without any exception overhead.

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

std::string_view sv = "12345";
int value;
auto [ptr, ec] = std::from_chars(sv.data(), sv.data() + sv.size(), value);  // C++17
if (ec == std::errc{}) {
    // success: value == 12345, ptr points one past the last consumed char
} else if (ec == std::errc::invalid_argument) {
    // not a number
} else if (ec == std::errc::result_out_of_range) {
    // overflows int
}

For the reverse direction, std::to_string allocates a new string on every call. std::to_chars writes into a caller-provided buffer with no allocation and no locale.

cpp
// C++17
char buf[32];
auto [end, ec2] = std::to_chars(buf, buf + sizeof(buf), 12345);  // C++17
if (ec2 == std::errc{}) {
    std::string_view result(buf, end);  // "12345" — zero allocation
}

// Older: convenient but allocates
std::string s = std::to_string(12345);

Performance guidance: from_chars and to_chars are typically 3–10x faster than their locale-aware counterparts (stoi, sprintf, to_string) and are preferred in hot paths or when parsing untrusted input.


std::string in C++23: new APIs

std::string::resize_and_overwrite (C++23) lets you grow a string and fill it via a callback without the zero-initialization that resize performs. This is the idiomatic way to populate a std::string from a C API that writes into a char* buffer.

cpp
// C++23
#include <string>
#include <cstdio>

int val = 42;
std::string s;
s.resize_and_overwrite(32, [&](char* p, std::size_t cap) -> std::size_t {  // C++23
    int written = std::snprintf(p, cap, "value=%d", val);
    return static_cast<std::size_t>(written);  // return actual characters written
});
// s == "value=42", s.size() == 8

Before C++23 the idiomatic workaround was resize(n) followed by writing into s.data(), which zero-initializes the buffer unnecessarily. resize_and_overwrite skips that initialization pass.

cpp
// C++23: range constructor — construct std::string from any contiguous range of char
#include <ranges>
std::vector<char> chars = {'h', 'i', '!'};
std::string from_range(std::from_range, chars);   // C++23 — "hi!"

std::string::contains is also new in C++23 (already shown in the Search section) and provides a cleaner alternative to the find(...) != npos idiom.

cpp
// C++23
std::string log_line = "[ERROR] disk full";
if (log_line.contains("ERROR")) {   // C++23
    handle_error(log_line);
}

See Also

  • std::string_view (C++17) — non-owning, zero-copy reference to a character sequence
  • std::format() (C++20) — type-safe formatted string construction
  • std::to_chars(), std::from_chars() (C++17) — fast, exception-free numeric conversion
  • std::ostringstream — when you need locale-aware formatting or older C++ standards
  • std::regex — regular expression matching and substitution
  • std::getline() — read a line from a stream into a std::string