AlgoMaster Logo

C-Strings (char arrays)

Medium Priority29 min readUpdated June 6, 2026
Listen to this chapter
Unlock Audio

A C-string is the original way C++ represents text: a plain array of char values that ends with a special null character \0. It's foundational, it shows up everywhere in older code and in C library APIs, and these appear in it any time a string literal sneaks into your program. For new code, the modern default is std::string (covered in the _std::string_ lesson). This chapter teaches C-strings honestly, so you can read them, debug them, and hand them off to functions that demand them.

What Is a C-String

A C-string isn't a special type. It's just a char array that follows one rule: the last character used is \0, called the null terminator. Every C-string is a char array, but not every char array is a C-string. The difference is whether that \0 is there.

A small C-string program that actually does something:

The variable product is a char array with six slots. Five of them hold the letters A, p, p, l, e. The sixth holds \0. The compiler added that sixth slot automatically because the initializer was a string literal. When std::cout prints product, it walks through the array printing each character until it sees the \0, then stops.

That stopping behavior is the whole point of the null terminator. Without it, the printer has no way to know when the string ends.

Compare this with a number array:

The array knows it has three slots because the compiler counted the initializer. But once you pass that array around (especially to a function), the size doesn't travel with it. A char array has the same problem, and C-strings solve it by stuffing the \0 at the end as a sentinel. "Read until you see \0" is the convention, and every C-string function in the standard library follows it.

So a C-string is really a contract between you and the library: "I promise the array has a \0 somewhere inside, and the text ends right before it." Break that promise and the library reads off the end of the array, which is undefined behavior.

The Null Terminator

The null terminator is the character \0, which is a char with the numeric value 0. Not the digit '0' (whose value is 48 in ASCII), but a zero byte. It's there as a marker, not as printable text.

You can see this difference yourself:

The two characters look similar when written, but they're different values. The \0 is what every C-string function uses to find the end. The '0' is just the digit zero you'd see on a keyboard.

Why does the null terminator exist at all? Because raw char arrays don't carry their length. When you write char product[] = "Apple", the array variable product knows its size (6) inside this one function. But the moment you pass product to another function, you lose that information. The array "decays" into a plain pointer to its first element, and a pointer has no built-in length.

The designers of C had two choices for representing strings:

  1. Store the length as a separate number alongside the array.
  2. Stuff a sentinel value at the end of the array and scan until you see it.

C picked option 2. The sentinel is \0, which is convenient because zero is rarely a meaningful value inside text. The price is that every function that touches a C-string has to walk the whole array to find the end, even if all you want is the length. That's a real cost, and we'll come back to it.

Finding the length of a C-string is O(n), where n is the length. The string doesn't store its size, so the only way to know it is to scan until \0. std::string (covered in the _std::string_ lesson) stores the length explicitly, so its .size() is O(1).

The visual model for "Apple" in memory looks like this:

Six cells in a row. The first five hold the letters, the sixth holds the null terminator (drawn in orange to highlight that it's the sentinel, not part of the visible text). When something reads this array as a C-string, it starts at index 0 and walks forward until it lands on the \0. The \0 itself is never printed; it just signals "stop."

Four Ways to Declare a C-String

There are four common ways to bring a C-string into existence. They differ in where the memory lives, whether the size is fixed, and whether you can modify the contents. Picking the wrong one is one of the most common sources of bugs in C-string code.

Form 1: Array Initialized From a String Literal

The compiler counts the characters in "Apple", adds one for the \0, and creates an array of size 6. The string literal's contents are copied into that array, so product is a real, modifiable array of char. You can change individual characters by index, and you can pass it to functions that need a mutable buffer.

This is the form to use when you want a string you can edit.

Form 2: Fixed-Size Array

You pick the size yourself. The array is exactly 6 slots: 5 for the letters of "Apple" and 1 for the \0. This is the form that demands the +1 rule: the array must be at least one larger than the visible characters, because the \0 needs its own slot.

If you make the size too small, the compiler stops you:

g++ reports something like:

If you make the size larger than needed, the extra slots are zero-filled:

The first \0 (at index 5) is the one that matters for printing. The trailing \0 bytes are filler; they don't hurt anything, but they also don't add to the visible string.

This form is useful when you need a buffer with extra room for characters to add later (for example, when reading user input into the array).

Form 3: Char-by-Char Initialization

Here you spell out each character individually. The array has 10 slots; the first 5 hold the letters, and the remaining 5 are value-initialized to zero, which for char means \0. So index 5 has \0, and std::cout stops printing there.

This form is the verbose cousin of Form 1. Most of the time one would write "Apple". It's worth knowing about because it appears in code that builds strings programmatically.

A subtle trap: if you fill all 10 slots with non-zero characters, you don't get a C-string. You get a char array with no terminator, and printing it is undefined behavior.

The +1 rule still applies. There must be at least one \0 somewhere, or the array isn't a valid C-string.

Form 4: Pointer to a String Literal

This form looks like the others, but it's different in an important way. product is a pointer, not an array. It points to the first character of the string literal "Apple", which lives in a read-only section of the program's memory. The contents are not copied; product just holds the address of the literal.

The const is doing real work here. String literals are not modifiable, and the type system enforces that by requiring you to use const char*. Trying to mutate the literal is undefined behavior:

If you drop the const (which the compiler allowed for backward compatibility with C in older standards, and now warns about):

The exact error depends on the compiler, but the lesson is the same: string literals are not yours to change. Use const char* when you want a non-modifiable view, and one of Forms 1, 2, or 3 when you want to edit the characters.

The four forms compared:

FormExampleStorageModifiable?SizeUse When
Array, auto-sizedchar p[] = "Apple";Stack array, copy of literalYesAuto (chars + 1)You want an editable string of known content
Array, fixed-sizechar p[6] = "Apple";Stack array, copy of literalYesYou set it (must include +1 for \0)You need a buffer with extra room
Char-by-charchar p[10] = {'A','p','p','l','e'};Stack array, extras zero-filledYesYou set itBuilding from individual characters
Pointer to literalconst char* p = "Apple";Pointer + read-only literalNoPointer is fixed; data is not yoursRead-only string, often interop with C APIs

Forms 1, 2, and 3 allocate a stack array and copy the literal's bytes into it on every function call where they appear. Form 4 just stores a pointer, no copy. For small strings, the copy is negligible. For long strings inside hot loops, it adds up.

The +1 Rule

Every C-string needs space for the null terminator. The size of the array must be at least one larger than the number of visible characters.

Visible TextRequired Array Size
"" (empty)1 (just the \0)
"A"2
"Apple"6
"Hello, world"13

Forgetting this rule is one of the easiest bugs to write. A buffer that's "exactly the right size" by character count is one byte too small to hold a C-string of that length.

When you let the compiler size the array with [], it does the math for you. When you size it yourself, you have to remember the +1.

Reading and Writing Individual Chars

A C-string is still an array internally, so you can use the subscript operator [] to read or write any character by its index. The first character is at index 0, the last visible character is at index n - 1, and the null terminator is at index n (where n is the visible length).

Index 5 holds the \0. Printing it directly with std::cout << product[5] would print nothing visible (it's a control character), so the cast to int makes the zero value visible.

You can mutate characters in-place when the underlying storage is modifiable (Forms 1, 2, 3):

You can also walk the string with a loop, using the \0 as the stop condition:

The loop stops the moment it reads \0. That's the canonical pattern for processing a C-string when you don't know its length: keep advancing until you hit the terminator.

Each pass through the loop reads one character and compares it to \0. The total work is O(n), where n is the length of the string. There's no shortcut, because the length isn't stored anywhere.

Why std::cout Knows to Print a C-String

You may have noticed that std::cout << product prints Apple, not the address of the array. That's not because << understands arrays in general. It's because the standard library overloads << to special-case const char*.

When you write std::cout << product, the array decays to a const char* pointing at its first element. std::cout has an overload for const char* that treats the pointer as a C-string: it walks forward, printing each character, until it hits \0.

Compare these two cases:

(The exact address varies from run to run.) The char array prints as text because of the const char* overload. The int array prints as a pointer because there's no special overload for int*. The "I know how to print this" behavior is specific to character pointers, not arrays in general.

That special-casing has a flip side. If you wanted to print the address of your C-string instead of its contents, you have to cast away the special treatment:

The cast to const void* strips the const char* overload and forces std::cout to print the raw pointer value. This trick comes up occasionally when debugging.

Memory Layout

A C-string lives in memory as a contiguous run of bytes. Each char is one byte (on every common platform). The array sits in stack memory if it's a local variable, in the data segment if it's a global or static, and the contents of a string literal live in a read-only section of the program image.

A diagram showing the difference between Form 1 (a modifiable array) and Form 4 (a pointer to a literal):

The diagram captures the two storage models. On the left, char product[] = "Apple" puts six bytes directly on the stack: a copy of the literal's contents that the program is free to modify. On the right, const char* code = "FRUIT" puts a single pointer on the stack, and the actual characters live in the read-only section of the program image. The pointer itself can be reassigned to point somewhere else, but the bytes it points at are off-limits.

This difference is the reason you can write product[0] = 'a' but not code[0] = 'f'. In the first case, you're modifying your own stack memory. In the second, you'd be modifying read-only memory, which the operating system actively prevents.

If two pointers in your program point to the same string literal, they may share storage. The compiler is allowed to merge identical literals to save space:

That's another reason mutating a string literal is dangerous. Even if your one line of code "works", you might be changing the value of every other literal that happened to share the same storage.

Common Pitfalls

C-strings are sharp tools. A few mistakes show up often enough to be worth naming.

Pitfall 1: Forgetting the Null Terminator

If you build a char array by hand and forget the \0, the array isn't a C-string. Printing it walks off the end into whatever bytes happen to live next door, until it eventually hits a zero.

What's wrong with this code?

The array has three characters and no \0. std::cout treats code as a const char* and walks forward looking for the terminator, reading whatever garbage sits in memory after index 2. The output might be ABC followed by random characters, or it might crash if the program walks into an unmapped page.

Fix:

Four slots now: three letters and one terminator. Or just use the string-literal form (char code[] = "ABC";) and let the compiler size and terminate it for you.

Pitfall 2: Writing Past the End

If your array has size 6 (holding "Apple" plus \0) and you write to index 6, you're past the array. The compiler usually won't catch it, the program might appear to work, and the bug might only surface later when the corruption causes a crash somewhere else.

What's wrong with this code?

Two bugs in three lines. First, overwriting product[5] removes the null terminator, so the string no longer ends at index 5. Second, product[6] is out of bounds, because the array only has indices 0 through 5. Writing there is undefined behavior: maybe it corrupts a nearby variable, maybe it doesn't, maybe the bug only appears on Tuesdays.

Fix:

Size up the array so the new character fits without overrunning. The +1 rule applies: 6 visible characters need 7 slots.

Pitfall 3: Mutating a String Literal

This is the most common version of the read-only-memory bug.

What's wrong with this code?

The compiler rejects this at the customer[0] = 'R' line, because customer points to const char. Some older code drops the const:

In C++11 and later, the bare char* form is a hard error from the compiler, not a warning. The fix is to either keep the pointer const (if you don't need to modify), or use an array (if you do):

The rule is simple: if the storage came from a string literal and was assigned to a pointer, don't modify it. If you need to modify, copy it into an array first.

Pitfall 4: Off-by-One in the Length

Because the null terminator takes a slot, the array size and the string length differ by one. Mixing them up is a steady source of bugs.

Just remember: the visible characters take length slots, and the \0 takes one more.

When C-Strings Still Appear

If std::string is better for new code, why does this chapter exist? Because C-strings are everywhere you can't escape them.

Interop with C libraries. Almost every operating-system API, every networking library, every database driver, every old image-processing library is written in C and takes const char* arguments. Functions like fopen, getenv, printf, every POSIX system call, the Windows API, and most graphics libraries speak C-strings natively. Converting std::string to a C-string to call them is common.

File paths. Most filesystem APIs (including std::fstream in older standards) accept const char* for file names. Even modern C++ (std::filesystem since C++17) lets you pass either type, but plenty of code still uses C-strings for paths.

String literals in your code. The moment you write "Apple" in a program, you've used a C-string. The literal has type const char[6], and it decays to const char* when passed around. Even when your variables are std::string, the literals you assign to them are C-strings internally.

Legacy and embedded code. Older codebases, kernels, drivers, and resource-constrained embedded systems often use C-strings exclusively because they avoid the dynamic allocation that std::string performs. If you ever read or maintain that code, you need to know how C-strings work.

Function signatures is written. Even in modern C++, occasionally write a function that accepts const char* because that's the most flexible "I just need to read some text" parameter type. It accepts string literals directly, accepts a char array, and accepts the result of .c_str() on a std::string. The _std::string_view_ lesson covers std::string_view, which is the modern replacement for this use, but const char* parameters still appear constantly in production code.

A practical example showing the literal-to-const char* link:

All three call sites pass C-string data through the same parameter type. Functions written this way work with literals, arrays, and pointers without any conversion code. That flexibility is exactly why const char* shows up so often as a parameter type.

A Brief Note: std::string Is the Modern Default

For new code, use std::string. It carries its length, grows automatically when you append to it, manages its own memory, supports rich operations like substring search and replacement, and avoids almost every category of bug listed above.

C-strings are still worth understanding, but you should not use a char array as the first tool when modeling text in a new program. Use std::string, then drop down to C-strings only when an external API forces you to.

That said, every std::string has an .c_str() method that returns a const char* view of its contents, which makes the two worlds easy to bridge:

The std::string owns the memory and the length tracking. The .c_str() call hands out a const char* that points into the string's buffer, guaranteed to be null-terminated. That bridge is what lets modern code use std::string everywhere while still calling C APIs that demand C-strings. Details on std::string itself wait for the _std::string_ lesson; for now, just know that the two forms can coexist.

Quiz

C-Style Strings Quiz

10 quizzes