std::string under the hood

How std::string works internally, optimizations it uses, and common mistakes

Most C++ developers use "std::string" every day. We concatenate strings, pass them to functions, compare them, and print them without giving much thought to what's happening internally.

But std::string is far more than just an array of characters. It's a class that balances performance, memory usage, and ease of use.

Let's look under the abstraction and see what happens under the hood.


What is std::string?

std::string is simply an alias for:

std::basic_string<char>

The C++ Standard Library defines a template called std::basic_string, and std::string specializes it for ordinary char characters.

Other common variants include:

std::wstring
std::u8string
std::u16string
std::u32string

Each stores a different character type, but internally they all follow the same general design.


Internal Representation

Although the C++ Standard doesn't dictate the exact layout, most implementations contain something similar to:

// Just a simplified example
class string {
    char* data_;
    size_t size_;
    size_t capacity_;
};

Where:

  • data_ points to the character buffer.
  • size_ stores the number of characters.
  • capacity_ stores how much memory has been allocated.

Size vs Capacity

One of the most misunderstood concepts is the difference between size and capacity.

std::string s = "Hello";

size()     // = 5
capacity() // = maybe 15

The string contains only five characters.

However, extra memory is already allocated to avoid reallocating every time another character is appended.

When you do:

s += " World";

No allocation happens if the new text still fits inside the existing capacity.


Dynamic Memory

Eventually, the string runs out of space.

For example:

std::string s = "Hello";

for (int i = 0; i < 100; i++)
    s += '!';

Eventually:

capacity = 15, but it needs 16 characters. It allocates a larger buffer, copies the existing data, destroy the old buffer and then continue writing.

Growing gradually instead of allocating every append keeps complexity close to O(n) instead of O(n²).

Most implementations (not all) double the capacity when growing.

Example:

15

30

60

120

240

The exact way it grows depends on the library implementation.


Small String Optimization (SSO)

One of the coolest optimizations is Small String Optimization.

Very small strings usually don't allocate memory at all.

Instead, the characters are stored directly inside the object itself.

Conceptually:

std::string

+-----------------------------------+
| inline buffer (15 chars) |
| size |
+-----------------------------------+

Instead of std::string being a pointer to a heap allocation

So this:

std::string s = "Hello";

often performs zero heap allocations.

This makes small strings incredibly fast.

Different standard libraries use different inline sizes:

  • 15 characters
  • 23 charachters
  • Or other implementation specific

The C++ Standard does not require SSO, but nearly every modern implementation uses it.


Why Store Capacity?

Suppose capacity didn't exist.

Appending would look like:

A

AB

ABC

ABCD

ABCDE

Every append would require:

  • Allocate new memory
  • Copy everything
  • Free old memory

That would be extremely slow.

Capacity prevents this by allocating additional room ahead of time.


Why a Null Terminator?

Even though std::string knows its length, C libraries expect null-terminated strings.

For example:

printf("%s\n", s.c_str());

The c_str() function returns a pointer ending in \0'.

Without that terminator, many C APIs wouldn't work correctly.


Copying a String

std::string a = "Hello";
std::string b = a;

This performs a deep copy, each string own its memory

Modern C++ abandoned Copy-On-Write because it caused thread-safety and performance issues.


Move Semantics

One of C++11's biggest improvements was move semantics.

std::string make()
{
    return "Hello";
}
std::string s = make();

Instead of copying every character, the internal buffer can simply be transferred.

Conceptually:

Before:
Temporary -> Heap -> Copy -> New Heap

After moving:
Temporary -> Heap -> Ownership transferred -> No copy

Moving a large string is often constant time.


Reserving Memory

If you know approximately how large a string will become, you can reserve memory in advance.

std::string text;
text.reserve(1000);

Now many appends happen without repeated reallocations.

This is especially useful when reading files or generating large outputs.


Common Mistakes

Assuming capacity equals size

std::string s = "Hello";

size() != capacity()

Capacity is often larger


Holding onto pointers

const char* ptr = s.c_str();

s += " World";

The append may reallocate memory, making ptr invalid.

Always reacquire the pointer after modifying the string.


Forgetting to reserve

Building very large strings without reserve() can trigger many unnecessary reallocations.


Is std::string Contiguous?

Yes.

Since C++11, the characters are guaranteed to be stored contiguously.

This means:

char* p = s.data();

is safe for direct access to the underlying character array


Final Thoughts

std::string may seem simple, but it's cleverly engineered:

  • Automatic memory management
  • Contiguous storage
  • Small String Optimization (SSO)
  • Capacity management to reduce reallocations
  • Move semantics for efficient transfers
  • Compatibility with legacy C APIs through null termination

Most of these optimizations are invisible to the programmer, which is exactly the point. You get an easy-to-use, high-performance string type without worrying about manual memory management.

Understanding how std::string works internally can help you write more efficient code, avoid common mistakes, and appreciate the sophisticated design decisions behind one of the most frequently used classes in modern C++.

Thanks for stopping by.