AlgoMaster Logo

std::string

High Priority25 min readUpdated June 6, 2026
Listen to this chapter
Unlock Audio

std::string is the modern C++ way to handle text. It lives in the <string> header, owns its memory, grows automatically when appended to, and behaves like a normal value type that can be copied, compared, and passed around. After raw char arrays with a null terminator, this is a relief. For almost any new code, std::string is the appropriate default.

Why std::string

The previous two chapters covered C-strings, which are arrays of char with a '\0' byte at the end. They work, but they put a lot of bookkeeping on the programmer. The size must be tracked, the buffer must never overflow, the null terminator must never be forgotten, and a separate function (strcmp) is required for comparison because == checks pointer addresses, not text. Each of these is a real bug class that has caused real outages.

std::string removes that bookkeeping. The class wraps a buffer, tracks the size, manages the memory, and provides == that compares text. The same job done both ways.

C-string version:

It works, but the size of fullName had to be picked upfront. Too small, and the concatenation walks off the end of the buffer, corrupting memory. Too big, and space is wasted. The compiler gives no warning if the size is wrong.

The same code with std::string:

No size picking. No buffer overflow risk. The + operator stitches the pieces together and fullName grows to fit. If lastName were a hundred characters long, the same code would still work without a single change.

Here is what std::string provides.

The <string> Header

To use std::string, include the <string> header:

<iostream> often transitively includes something that makes std::string work, but that is an implementation accident, not a guarantee. Include <string> explicitly when it is used. Code that compiles on one compiler without the explicit include can fail on another.

The full type name is std::string. The std:: prefix is the namespace the standard library lives in. Most lessons in this course use the explicit prefix instead of using namespace std; because it shows clearly where each name comes from.

Constructing Strings

A few common ways to make a std::string:

What each line does:

  • std::string empty; builds an empty string. It has size 0 and prints as nothing.
  • std::string fromLiteral = "Wireless Mouse"; builds a string from a C-string literal. The literal gets copied into the new std::string's own buffer.
  • std::string copy = fromLiteral; makes an independent copy. Modifying copy later does not affect fromLiteral.
  • std::string repeated(5, '*'); builds "*****": five copies of one character. Useful for separators or padding.
  • std::string fromSubstring(fromLiteral, 9, 5); builds a new string from a slice of fromLiteral, starting at index 9 and taking 5 characters.

Initialization with = and initialization with parentheses both work, but they are not always interchangeable. std::string s = "abc"; and std::string s("abc"); produce the same result. std::string s(5, '*'); and std::string s = (5, '*'); do not, because the second one uses the comma operator and produces a single character. Use parentheses when passing arguments other than a single literal.

Constructing a std::string from a C-string literal copies the bytes into a buffer the string owns. For short strings, that buffer may live inside the string object itself (more on this below). For long strings, it lives on the heap.

Concatenation

std::string overloads + and += for concatenation. Both produce a string that contains the left side followed by the right side.

A few details:

  • The right side of + and += can be a std::string, a C-string literal, or a single char. All three work.
  • + produces a new string. It does not modify either operand.
  • += modifies the left side in place and returns a reference to it.

That second point matters for performance. + has to allocate space for the result, copy both operands into it, and return the new string. += can often grow the existing buffer (if capacity is available) and append the new characters, which avoids an allocation.

a = a + b allocates a new string, copies both a and b into it, and assigns. a += b appends b to a's existing buffer (allocating only if the buffer is too small). For repeated appending in a loop, prefer +=.

A common case: building a product title from parts.

You can also concatenate a char directly:

Not useful for real code, but it shows that char is a valid right-hand operand for +=.

One thing that does not work is concatenating two C-string literals with +:

g++ reports:

Both sides are C-string literals (raw const char arrays), and the language does not define + for two of those. To fix it, one side has to be a std::string:

In typical code, a std::string variable is on one side anyway, so this issue comes up mainly when writing one-line constants.

Comparison

std::string supports ==, !=, <, <=, >, >= directly. == and != compare the text. <, <=, >, >= compare lexicographically, which is character-by-character using each character's numeric value.

This is one of the biggest improvements over C-strings. With C-strings, == compares pointer addresses, not text, and almost always gives the wrong answer. With std::string, == compares the text.

Lexicographic comparison works through the strings character by character. The first character where they differ decides the order. If one string is a prefix of the other, the shorter one is "less."

The third comparison is the surprising one. 'A' is 65 in ASCII and 'a' is 97, so uppercase letters sort before lowercase ones. "App" is "less than" "apple" because the first character 'A' (65) is less than 'a' (97). For case-insensitive comparison, either write a custom comparison or convert both strings to the same case first.

You can also compare a std::string with a C-string literal:

The literal gets implicitly compared as a string. This is the most common form of comparison.

Element Access

Read or write individual characters in a std::string using [] or .at(). They both take a zero-based index and return a reference to that character.

The difference between [] and .at() is what happens out of bounds. [] is undefined behavior: the program may crash, may read arbitrary memory, or may appear to work and corrupt memory elsewhere. .at() does a bounds check and throws std::out_of_range if the index is invalid.

(The exact message varies between standard library implementations, but the type is always std::out_of_range.)

Which to use? Use [] when the index is already known to be in range (for example, inside a loop bounded by .size()). Use .at() when the index came from user input or an untrusted source and an exception is preferable to memory corruption.

There are also convenience methods for the first and last character:

.front() is equivalent to s[0] and .back() is equivalent to s[s.size() - 1]. Calling either on an empty string is undefined behavior, so check .empty() first if the string might be empty.

[], .at(), .front(), and .back() are all O(1). .at() is slightly slower than [] because of the bounds check, but for most code that difference does not matter.

Size and Empty

std::string knows its own size in constant time. There is no need to walk through the string counting characters the way strlen does on a C-string.

A few points:

  • .size() and .length() are identical. They both return the number of characters (not counting any null terminator). The two names exist for historical reasons. Pick one and use it consistently. Most modern code uses .size() because it matches the convention in other containers (std::vector::size(), std::array::size()).
  • .empty() returns true if the string has zero characters. It is the same as writing s.size() == 0, but reads more clearly and is the conventional way to check.
  • Both methods are O(1). std::string stores the size in a member, so reading it does not require walking the buffer.

There is also .capacity(), which returns how many characters the string can hold without reallocating its buffer. It rarely needs explicit attention. std::string grows automatically as appending happens, so the capacity adjusts on its own. The one case where it matters is appending in a loop where repeated reallocations should be avoided: calling .reserve(n) upfront tells the string to allocate space for n characters now. That is a tuning detail to address once profiling shows it matters.

.size(), .length(), and .empty() are O(1) on std::string. The equivalent on a C-string is strlen(), which is O(n) because it has to scan to the null terminator. That is one of the practical reasons std::string is preferred for any code that asks the length more than once.

Reading Input

There are two common ways to read a std::string from standard input: std::cin >> word and std::getline(std::cin, line). They behave differently, and picking the wrong one is a frequent source of bugs.

std::cin >> word reads one whitespace-separated word at a time. It skips leading whitespace, then reads characters until it hits whitespace again, and stops there. The whitespace is left in the input buffer.

If the user types Ada Lovelace, only Ada ends up in firstName. The space and Lovelace are still sitting in the input buffer, waiting for the next read.

Sample run:

std::getline(std::cin, line) reads the rest of the current line, including spaces, up to (but not including) the newline. It is the appropriate choice for reading a full name, an address, or a search query.

Sample run:

A common bug is mixing the two. If std::cin >> something runs first and then std::getline, the getline reads whatever is left on the line, which is often the newline character alone, giving an empty string.

Sample run:

The std::cin >> productId consumes the number but leaves the trailing newline in the buffer. std::getline then reads up to that newline, which is right there, so it gets an empty string and productName is empty.

The fix is to clear the leftover newline before calling std::getline:

Sample run:

The std::cin.ignore(...) call throws away everything up to and including the next newline, so getline starts fresh on the next line of input. This pattern applies any time >> is mixed with getline.

Interop with C-Strings

Some functions and APIs take a const char*. The .c_str() method returns a pointer to a null-terminated C-string view of the string's contents.

A few rules for .c_str():

  • The returned pointer is const char*. The characters can be read but not modified.
  • The pointer is valid until the string is modified or destroyed. After an append or after the string goes out of scope, the pointer is no longer safe to use.
  • The string contents are always null-terminated when accessed through .c_str().

Since C++11, there is also .data(). For most purposes, .data() and .c_str() behave the same: both return a pointer to the buffer, and both are null-terminated. The historical difference (pre-C++11, .data() was not guaranteed null-terminated) is gone, so use whichever name reads better. Most code still uses .c_str() because the name signals "a C-string view of this".

.c_str() is O(1) since C++11. The implementation maintains the null terminator as part of the buffer, so no copy or allocation happens on the call.

Going the other way is simpler. A const char* (which is what a C-string literal decays to) can be implicitly converted to a std::string, so C-strings can be passed anywhere a std::string is expected:

The function greet takes a const std::string&, but a C-string can be passed and the compiler constructs a temporary std::string from it for the duration of the call. This makes mixing std::string with older C-string-based code mostly painless.

Internal Layout

The internals of std::string are not required knowledge, but a rough picture helps explain why some operations are fast and others are not.

A std::string object holds three pieces of information: a pointer to the character buffer, the size (current number of characters), and the capacity (how many characters the buffer can hold). A call to .size() returns the size member directly. An append writes into the buffer up to the capacity, and only reallocates when no room is left.

The diagram shows the conceptual layout for a longer string. The std::string object itself sits in whatever scope it was declared in (often the stack), and it holds a pointer to a separate buffer that lives on the heap. The size says how many characters are in use; the capacity says how many slots the buffer has total.

One important detail: small string optimization (SSO). Most standard library implementations skip the heap allocation entirely for short strings (typically up to 15 or 22 characters, depending on the implementation) by storing the characters inside the std::string object itself, in space that would otherwise be unused. This is invisible from the outside, the data API is the same, but it means creating short strings is essentially free. A std::string s = "ok"; does not trigger a heap allocation.

The exact cutoff and the SSO implementation are not required reading. The takeaway: short std::string values are not heavy.

Why std::string Beats char[]

A side-by-side comparison.

Aspectchar[] (C-string)std::string
Knows its own sizeNo, you call strlen, which is O(n)Yes, .size() is O(1)
Grows when neededNo, fixed size at declarationYes, automatically
Manages its own memoryNo, you allocate and freeYes, RAII handles it
Safe from buffer overflowNo, easy to overflowYes, grows or throws
== compares textNo, compares pointer addressesYes
+ for concatenationNo, use strcat (and pre-allocate)Yes
Copyable with =No, that just copies the pointerYes, full deep copy
Comparable with < for sortingNo, use strcmpYes, lexicographic
Works directly with standard containersAwkwardNatural
Interop with C APIsNativeOne call to .c_str()

Every row in the C-string column is a real bug source. Forgetting to allocate enough space, forgetting the null terminator, using == thinking it compares text, leaking memory because no one called delete[]. std::string removes those failure modes.

That is what the advice "use std::string for all text" means. For new code, there is almost no reason to use char[]. The exceptions are narrow: interfacing with a C API that requires a writable char*, or an embedded environment where heap allocation is not available. For everything else, std::string is the default.

Other String Types

std::string holds char values, which are typically one byte each and work well for ASCII text. For other character types, the standard library provides parallel types:

  • std::wstring holds wchar_t, used on Windows for "wide" characters.
  • std::u8string (since C++20) holds char8_t, for UTF-8 encoded text.
  • std::u16string holds char16_t, for UTF-16.
  • std::u32string holds char32_t, for UTF-32.

They all share the same API as std::string. The trade-offs around when to use each one (and how to handle Unicode properly in general) are beyond this lesson. For the rest of this course, "string" means std::string, which is the default for almost any code.

Quiz

String Class Quiz

9 quizzes