C++ programmers spend most of their time inside std::string, but the world outside — operating system APIs, the C standard library, third-party SDKs written in C — still speaks in null-terminated character arrays. The bridge between the two is c_str(), and the property that makes it efficient is also the property that makes it dangerous: it returns a pointer into the string’s own buffer, without copying anything. No allocation, no strcpy, no extra work. Free, but only as long as the string it came from is alive and unmodified.
Three things to take away:
c_str()returns aconst char*to the string’s internal, null-terminated buffer — no copy, no allocation.- The pointer is invalidated by any non-
constoperation on the source string, and by the string’s destruction. - For read-only views that don’t need null termination, prefer
std::string_view; reach forc_str()only when the consumer genuinely requires a null-terminated C string.
What c_str() actually does
The reference description from O’Reilly’s C++ in a Nutshell is characteristically blunt: c_str() “returns a pointer to a null-terminated (C-style) character array that contains the same characters as the string.” That is the entire feature. There is no copy step. The pointer aims directly at the storage the std::string is already using to hold its characters, and the trailing '\0' has been there all along — the standard requires std::string to maintain a null terminator past its last character precisely so that c_str() can be a free operation.
#include <cstdio>
#include <string>
int main(){
std::string fmt{ "Pi is approximately %.4f\n" };
std::printf(fmt.c_str(), 3.14159);
}
std::printf has no idea std::string exists. It wants a const char* ending in '\0'. c_str() produces exactly that, without copying the format string into a separate buffer. This is the canonical use case: handing a std::string to a C function that expects a C string.
The invalidation rule
The pointer’s efficiency comes from the fact that it aliases the string’s internal storage. The cost is that anything which moves or modifies that storage invalidates the pointer. The rule covers more than it first appears to:
std::string s{ "hello" };
const char* p = s.c_str(); // p aims into s's buffer
s += " world"; // s may have reallocated — p now dangles
std::puts(p); // undefined behaviour
Any non-const operation — +=, push_back, resize, clear, erase, insert, replace, even a non-const operator[] — is permitted to invalidate the pointer returned by an earlier c_str() call. Destroying the string invalidates it absolutely. The lifetime model is: get the pointer, use it immediately, do not store it past the next mutation of the source.
The most common form of this bug is more insidious than the one above:
const char* getName(){
std::string name = lookupName();
return name.c_str(); // BUG: name dies at the closing brace
}
name is a local variable. The pointer it produced is dangling the moment getName returns. The function compiles, often runs “correctly” in debug builds, and corrupts in production. Returning c_str() from a function that owns the underlying string is almost always wrong; return the std::string itself and let the caller call c_str() on the live object.
c_str() vs data() — and why the answer changed
Historically, c_str() and data() were two slightly different functions. Both returned a pointer to the string’s character storage, but only c_str() guaranteed null termination — data()‘s buffer was not required to end in '\0'. The 2003-era reference makes this distinction explicit: data() “returns a pointer to a character array… Note that the character array is not null-terminated.”
C++11 changed that. As of C++11, std::string::data() is guaranteed to return a null-terminated buffer just like c_str(), and the two const-qualified overloads are functionally equivalent. C++17 went a step further and added a non-const data() that returns char*, allowing controlled in-place mutation of the buffer (subject to the same null-termination contract).
The practical guidance for modern code:
- Use
c_str()when calling C APIs. Its name documents the intent. - Use
data()when you want a pointer to the buffer for reasons other than null-terminated C interop — for example, passing the buffer to a binary-data function alongsidesize(). - Both have the same lifetime rules: invalidated by non-
constoperations on the source string.
When you don’t need null termination at all
A surprising amount of code calls c_str() not because the consumer needs null termination, but because the author needed some const char* and c_str() was the function they remembered. For a read-only view into a string — passing a substring to a parser, looking up a key, comparing prefixes — the modern answer is std::string_view:
#include <string_view>
bool startsWithGreeting(std::string_view text){
return text.starts_with("hello");
}
std::string s{ "hello world" };
startsWithGreeting(s); // implicit conversion, no allocation
startsWithGreeting("hi there"); // also works — string literal
std::string_view is a non-owning pair of (pointer, length). It does not require null termination, and it accepts both std::string and string literals without converting between them. It is also subject to the same lifetime caveat — it does not extend the lifetime of what it views — but it makes the non-ownership explicit at the type level, which c_str() does not.
The dividing line is simple: if the consumer reads characters by length, prefer string_view. If the consumer reads characters until it hits '\0', you need c_str().
Takeaway
c_str() is one of the cheapest function calls in the standard library — it returns a pointer into existing storage without copying or allocating — and that efficiency is exactly what makes its lifetime rules unforgiving. Use it for genuine C interop and use it immediately, never storing the pointer past the next mutation of the source string and never returning it from a function whose local string owns the buffer. For read-only views that don’t terminate at '\0', reach for std::string_view and keep c_str() for the cases that genuinely need a C string. The rule is simple: c_str() is a view, not a copy — treat it like one.