Arrays & Strings · beginner · ~10 min
## What you will learn - Explain the C string convention: a `char *` that points at a run of bytes ending in a NUL terminator (`'\0'`). - Tell the difference between a string's **length** (`strlen`) and the **capacity** of the buffer that holds it. - Use `<string.h>` functions like `strlen`, `strcpy`, and `strcat` correctly, and recognize when each one is unsafe. - Choose bounded alternatives (`snprintf`, `strlcpy`, `strlcat`) and prove your destination buffer is big enough. - Avoid the most common string bugs: missing terminators, writing through a read-only literal, and calling `strlen` in a loop condition. - Read raw string memory in a debugger to diagnose corruption.
C has no string type. What we call a string is just a plain array of char whose contents end with a special byte: the NUL terminator, written '\0' (a byte with value zero). When you see char *s used as a string, s points at the first byte, and the string continues byte-by-byte until that zero byte is reached.
This builds directly on Character arrays, your prerequisite. There you learned that a char is a single byte and that you can store many of them in an array. A C string adds exactly one rule on top of that array: put a zero byte at the end so the rest of the program knows where the text stops.
The string "hello" stored in a char array:
index: 0 1 2 3 4 5
+----+----+----+----+----+----+
bytes: | h | e | l | l | o | \0 |
+----+----+----+----+----+----+
'h' 'e' 'l' 'l' 'o' 0
length = 5 (bytes before the \0)
capacity needed = 6 (those 5 + room for the \0)
Text is the universal data format. URLs, filenames, HTTP headers, log lines, JSON, configuration files, and even C source code are all strings. Almost every program you will ever write reads or produces text. In C, that means you are constantly working with this byte-array-plus-terminator convention.
'\0' byte marking the end. (Note: "NUL" is one L; it is not the same as the NULL pointer.)"hello". The compiler stores it for you and adds the terminator automatically.size_t — the unsigned integer type used for sizes and lengths (what strlen returns).Everything else about strings in C is just array arithmetic done while keeping a careful eye on that terminator.
Text is how programs talk to humans and to each other. A web server parses HTTP request lines; a shell splits a command into words; a database stores names and addresses; a log writer formats messages. All of that is string handling. If you cannot work with C strings confidently, you cannot write real C programs.
C's bare-bones string model is a double-edged sword.
| Upside | Downside |
|---|---|
| Extremely fast — no hidden allocation, no copying you did not ask for | You must check every length and every bound yourself |
| Predictable memory layout, ideal for systems code | A single missing terminator can read or write far past your buffer |
| Works the same on every platform | No built-in Unicode, no automatic growth |
Because the language tracks no length and does no bounds checking, string mistakes are the classic source of memory-corruption bugs. A large share of historically reported C/C++ memory-safety vulnerabilities trace back to buffer overflows, and string copies are one of the most common ways those overflows happen. Learning to handle strings safely is therefore not just about correctness — it is about not shipping exploitable code.
Definition. The NUL terminator is a single byte with value zero ('\0') placed immediately after the last real character of a string.
Why it exists. C does not store a string's length anywhere. The only way any function knows where the text ends is by scanning forward until it hits this zero byte. The terminator is the end-of-string marker.
How it works internally. strlen, printf("%s", ...), strcpy, and friends all loop reading one byte at a time and stop the instant they read a '\0'. No terminator means no stopping point.
With terminator (correct): Without terminator (bug):
+---+---+---+----+ +---+---+---+----+----+----+
| h | i | ! | \0 | <- strlen=3 | h | i | ! | ?? | ?? | ?? | ...
+---+---+---+----+ +---+---+---+----+----+----+
strlen stops here ^ strlen keeps reading garbage ----->
When to rely on it. Always, when calling standard library string functions — they assume it is present.
Pitfall. Functions like strncpy may leave the destination without a terminator if the source is too long. After such calls you often must place the '\0' yourself.
Knowledge check (predict the output): A buffer holds the bytes
'a','b','c'with no zero byte after them. What is the behavior ofstrlenon that buffer?
Definition. Length is how many characters the string currently has (strlen(s), the count of bytes before the NUL). Capacity is how many bytes the underlying buffer can hold.
The key relationship. A buffer must hold at least strlen(s) + 1 bytes — the +1 is room for the terminator. Forgetting that +1 is one of the most common beginner bugs.
char buf[8]; // capacity = 8 bytes
+---+---+---+---+----+---+---+---+
| h | e | l | l | o |\0 | ? | ? | length = 5, capacity = 8: fits
+---+---+---+---+----+---+---+---+
used (6 bytes incl. \0) spare
When to track capacity. Every time you write into a buffer. Reading is governed by the terminator; writing is governed by capacity.
Pitfall. Computing space as strlen(src) instead of strlen(src) + 1 leaves no room for the terminator and overflows by one byte (an "off-by-one").
Definition. A string literal such as "hello" is a fixed, unnamed array the compiler stores for you (6 bytes here: 5 characters + NUL).
How it works. Literals typically live in a read-only region of memory. Pointing at one is fine; modifying its bytes is undefined behavior (UB) and commonly crashes.
char *p = "hello"; // p points at a read-only literal
p[0] = 'H'; // UNDEFINED BEHAVIOR — may crash
char a[] = "hello"; // a is YOUR OWN writable copy on the stack
a[0] = 'H'; // fine: a is now "Hello"
When to use which. Use char *p = "..." (or better, const char *p = "...") when you only read. Use char a[] = "..." when you need to modify the text.
Pitfall. char *p = "..." compiles without warning but invites a write-through-literal crash later. Mark such pointers const so the compiler stops you.
Definition. Because length is not stored, finding it requires scanning the whole string, which is O(n) work (it grows with the string's length).
The classic anti-pattern. Putting strlen(s) in a loop condition re-scans the string on every iteration, turning an O(n) loop into O(n^2).
for (size_t i = 0; i < strlen(s); i++) { ... } // O(n^2): strlen runs every pass
size_t n = strlen(s); // compute once
for (size_t i = 0; i < n; i++) { ... } // O(n)
Knowledge check (find the bug): Why does
for (int i = 0; i < strlen(s); i++)get slower and slower assgrows, even when the loop body is trivial?
Knowledge check (explain in your own words): In one sentence, what is the difference between a string's length and its buffer's capacity, and why must capacity be at least length + 1?
#include <string.h> // strlen, strcpy, strcat, etc.
#include <stdio.h> // snprintf
char s[] = "hello"; // mutable copy: 6 bytes on the stack (5 + NUL)
const char *p = "hello"; // read-only literal; never write through p
size_t n = strlen(s); // 5 — counts bytes BEFORE the NUL, not the NUL
char buf[64];
strcpy(buf, s); // UNSAFE: no size limit; overflows if s is long
snprintf(buf, sizeof buf, "%s", s); // SAFE: writes at most 63 chars + NUL
char name[16];
snprintf(name, sizeof name, "User-%d", 42); // formats and bounds in one call
Key points: strlen excludes the terminator; sizeof buf gives the buffer's capacity (works only on real arrays, not on a char *); and snprintf always NUL-terminates as long as the buffer size is at least 1.
A C string is a char * pointing at a sequence of bytes that ends in \0.
The standard library functions in <string.h> all rely on this convention:
strlen walks to the NUL.strcpy copies up to and including the NUL.Because the length is not stored anywhere, any operation that needs it must scan the whole string. That makes it O(n) (the work grows with the length of the string).
Calling strlen in a loop condition is the classic O(n²) anti-pattern: the scan repeats on every iteration.
#include <stdio.h>
#include <string.h>
/* Safely build a greeting line from a user-supplied name.
Demonstrates: strlen, capacity checking, read-only literals,
and bounded formatting with snprintf. */
int main(void) {
const char *literal = "hello"; // read-only string literal
size_t len = strlen(literal); // 5 — bytes before the NUL
printf("\"%s\" has length %zu\n", literal, len); // %zu prints size_t
/* A writable copy we are allowed to modify. */
char name[] = "ada";
name[0] = 'A'; // OK: name is our own array -> "Ada"
/* Build "User: Ada (3)" into a fixed buffer, safely. */
char line[64];
int written = snprintf(line, sizeof line,
"User: %s (%zu)", name, strlen(name));
if (written < 0) { // encoding error
fprintf(stderr, "formatting failed\n");
return 1;
}
if ((size_t)written >= sizeof line) { // output was truncated
fprintf(stderr, "warning: output truncated\n");
}
printf("%s\n", line);
return 0;
}
What it does. It measures a string literal's length, makes a writable copy of a name and edits its first character, then formats a labeled line into a 64-byte buffer using snprintf (which can never overflow). It checks snprintf's return value for both errors and truncation.
Expected output:
"hello" has length 5
User: Ada (3)
Edge cases. If name were long enough that "User: ... (n)" exceeded 63 characters, snprintf would truncate to fit and return the length it would have written — which is why the >= sizeof line check matters. Writing literal[0] = 'H' instead of editing name would be undefined behavior because literal points at a read-only literal.
Walkthrough of the key example:
| Step | Code | What happens |
|---|---|---|
| 1 | const char *literal = "hello"; |
literal points at the 6-byte read-only array h e l l o \0. |
| 2 | strlen(literal) |
Scans h,e,l,l,o, stops at \0; returns 5. |
| 3 | printf(... %zu ...) |
Prints "hello" has length 5. %zu is the correct format for size_t. |
| 4 | char name[] = "ada"; |
Allocates a 4-byte writable array on the stack: a d a \0. |
| 5 | name[0] = 'A'; |
Overwrites the first byte; the array is now A d a \0 = "Ada". Legal because name is our own copy. |
| 6 | snprintf(line, 64, "User: %s (%zu)", name, strlen(name)) |
Formats User: Ada (3) into line, writing at most 63 characters plus a guaranteed \0. Returns 13. |
| 7 | written < 0 check |
written is 13, not negative, so no error. |
| 8 | (size_t)written >= sizeof line |
13 >= 64 is false, so no truncation warning. |
| 9 | printf("%s\n", line) |
Walks line to its \0, printing User: Ada (3). |
Memory at step 6:
line (64 bytes):
+---+---+---+---+---+---+---+---+---+---+---+---+---+----+----+...
| U | s | e | r | : | | A | d | a | | ( | 3 | ) | \0 | ?? |
+---+---+---+---+---+---+---+---+---+---+---+---+---+----+----+...
0 1 2 3 4 5 6 7 8 9 10 11 12 13 (unused)
The terminator at index 13 is what makes the later printf("%s", line) stop in the right place.
strlen on non-terminated datachar buf[3] = { 'a', 'b', 'c' }; // WRONG: no room for, and no, NUL
size_t n = strlen(buf); // reads past the end -> garbage / crash
Why it is wrong. strlen keeps reading until it finds a zero byte. With no terminator it walks into whatever memory follows. Fix: leave room and terminate.
char buf[4] = { 'a', 'b', 'c', '\0' }; // or: char buf[] = "abc";
size_t n = strlen(buf); // 3
How to spot it: lengths that change run to run, or values much larger than expected.
strlen in the loop conditionfor (size_t i = 0; i < strlen(s); i++) // WRONG: O(n^2)
s[i] = toupper((unsigned char)s[i]);
Why it is wrong. strlen re-scans the entire string on every iteration. Fix: compute it once.
size_t n = strlen(s);
for (size_t i = 0; i < n; i++)
s[i] = toupper((unsigned char)s[i]);
How to spot it: programs that are fine on short input but crawl on long input.
char *p = "hello"; // WRONG intent: literal is read-only
p[0] = 'H'; // undefined behavior — often a crash
Fix: make your own writable array, and mark read-only pointers const.
char a[] = "hello"; a[0] = 'H'; // fine
const char *p = "hello"; // compiler now blocks accidental writes
How to spot it: a crash (segmentation fault) on a line that assigns into a char * that was set from a literal.
char dst[5];
strcpy(dst, "hello"); // WRONG: "hello" needs 6 bytes (5 + NUL)
Why it is wrong. The 6th byte (the terminator) is written one past the end of dst. Fix: size for strlen(src) + 1, or use a bounded copy.
char dst[6]; // room for 5 + NUL
snprintf(dst, sizeof dst, "%s", "hello"); // or strlcpy
How to spot it: AddressSanitizer reports a stack-buffer-overflow of exactly one byte.
warning: passing argument ... discards 'const' qualifier — you are trying to modify a const char *. Either copy the data into a writable buffer or rethink why you need to change a constant.format '%s' expects argument of type 'char *' — you passed an int or a single char where a string pointer was expected.-Wall -Wextra. These warnings catch many string bugs before you ever run the program.strlen(src) + 1.%s — almost always a missing terminator. Cap the print with printf("%.10s\n", s) so the program survives long enough to inspect.+1 for the terminator.-Wall -Wextra -fsanitize=address and run; ASan pinpoints overflows and use-after-free with exact byte offsets.gdb, dump raw bytes to see whether the terminator is present: x/16cb s prints 16 bytes as characters.strlen and the buffer's sizeof next to each other to confirm the data actually fits.'\0' at the end of this data?strlen(src) + 1?strcpy, strcat, sprintf, and gets are unbounded: they keep writing until they reach a NUL in the source, with no regard for how big the destination is. If the source is longer than the destination, they overflow the buffer — corrupting adjacent memory and creating classic security vulnerabilities. (gets is so dangerous it was removed from the C standard entirely; never use it.)
strcpy(dst /*8 bytes*/, src /*"a very long string"*/):
dst: [ a v e r y ][ overflow -> corrupts whatever is next ]
\__ 8 bytes you own _/ \_____ memory you do NOT own ______/
| Avoid | Prefer | Why |
|---|---|---|
strcpy |
snprintf / strlcpy |
takes a destination size; cannot overrun |
strcat |
snprintf / strlcat |
bounded append |
sprintf |
snprintf |
bounded formatting |
gets |
fgets |
takes a buffer size |
strcpy(dst, src), you must be able to prove dst has at least strlen(src) + 1 bytes. If you cannot prove it, use a bounded version.strncpy, manually set the last byte to '\0' — it does not always terminate.sizeof buf (not a hand-typed number) as the size argument so the limit stays correct if the buffer changes.sizeof on a char * parameter expecting the buffer size — it returns the pointer size (often 8), not the array length.GET /index.html HTTP/1.1 by scanning strings for spaces and newlines.snprintf and known capacities are essential).For beginners (do these always):
snprintf for any formatting into a buffer; pass sizeof buf as the size.const char *.strlen once, not inside loop conditions.+1).For more advanced work:
snprintf's return value to detect truncation, and decide deliberately whether truncation is acceptable.-Wall -Wextra -fsanitize=address,undefined so string overflows fail tests instead of shipping.1. Reimplement strlen. Write size_t my_strlen(const char *s) that returns the number of bytes before the NUL, without calling the library.
my_strlen("hi") -> 2; my_strlen("") -> 0.s[i] == '\0'.2. Length vs. capacity check. Write a main that declares char buf[8], copies "hello" into it only if it fits (strlen(src) + 1 <= sizeof buf), and prints whether it fit.
strcpy.snprintf and compare its return value to sizeof buf.3. Reimplement strcpy safely. Write int safe_copy(char *dst, size_t dst_size, const char *src) that copies src into dst, never writes past dst_size, always terminates, and returns 0 on success or -1 if src did not fit.
"abc" into an 8-byte buffer returns 0; copying it into a 2-byte buffer returns -1 and leaves dst terminated.strlen(src) + 1 <= dst_size.4. Uppercase in place. Write void to_upper_inplace(char *s) that uppercases every alphabetic character in a writable string.
strlen once; use toupper((unsigned char)c)."aBc!" becomes "ABC!".strlen in the condition.5. Bounded join. Write int join(char *dst, size_t dst_size, const char *a, const char *b, char sep) that builds a + sep + b into dst, never overflows, always terminates, and returns the number of characters it would have written (like snprintf), so the caller can detect truncation.
join(buf, 64, "left", "right", '-') puts "left-right" in buf and returns 10.snprintf(dst, dst_size, "%s%c%s", a, sep, b) does almost all of this — then reason about its return value.char array (pointed at by a char *) that ends in a NUL terminator, '\0'. There is no string type and no stored length.strlen, bytes before the NUL) is not the same as capacity (buffer size). A buffer must hold at least strlen(s) + 1 bytes.strlen and friends scan for the terminator, so they are O(n) — never put strlen in a loop condition.char a[] = "..." when you need to modify text, and const char * when you only read.strcpy, strcat, sprintf, and gets. Prefer snprintf, strlcpy, and strlcat, and always pass sizeof buf as the limit.-Wall -Wextra -fsanitize=address to catch them early.