C lesson 5 of 8
Arrays and Strings in C
How a C array stores a fixed run of same-typed values, why a string is just a char array with a zero byte on the end, how strlen and the string.h functions use that terminator, and why writing past the end of a buffer is so dangerous.
Published · Every example on this page was run before it was published.
An array in C is the simplest possible collection: a fixed number of values of one type, laid out back to back in memory, reached by a number. There is no length stored alongside them, no bounds checking, and no way to grow one. Everything else in this lesson - including the whole of how C handles text - follows from those three absences.
Declaring and Indexing an Array
An array declaration names the element type, the array name, and the number of elements in square brackets. A brace-enclosed list gives the initial values.
#include <stdio.h>
int main(void) {
int scores[5] = {88, 92, 79, 95, 84};
printf("first: %d\n", scores[0]);
printf("third: %d\n", scores[2]);
printf("last: %d\n", scores[4]);
scores[1] = 100;
printf("second is now: %d\n", scores[1]);
return 0;
}first: 88
third: 79
last: 84
second is now: 100Indexes start at zero. An array of five elements is numbered 0, 1, 2, 3, 4, and scores[5]
is one past the end - not the last element. That is worth saying out loud a few times, because it is the
source of more C bugs than any other single thing.
The size in the declaration must be something the compiler can work out, and once fixed it never changes.
There is no scores.length, no push, and no append. If you need a collection whose size is decided
while the program runs, that is the dynamic-memory lesson.
A loop is how you visit every element, and the i < n shape from the control-flow lesson is exactly
right here:
#include <stdio.h>
int main(void) {
int scores[5] = {88, 92, 79, 95, 84};
int total = 0;
for (int i = 0; i < 5; i++) {
printf("scores[%d] = %d\n", i, scores[i]);
total += scores[i];
}
printf("total: %d\n", total);
printf("average: %.1f\n", (double) total / 5);
return 0;
}scores[0] = 88
scores[1] = 92
scores[2] = 79
scores[3] = 95
scores[4] = 84
total: 438
average: 87.6Initialisation Shortcuts
Give fewer initialisers than there are elements and the rest are set to zero. Give none at all, with
= {0}, and the whole array is zero. Leave the size out and let the initialiser decide it.
#include <stdio.h>
int main(void) {
int counts[6] = {4, 7};
int cleared[4] = {0};
int sized[] = {10, 20, 30};
for (int i = 0; i < 6; i++) {
printf("counts[%d] = %d\n", i, counts[i]);
}
printf("cleared[3] = %d\n", cleared[3]);
printf("sized has %zu elements\n", sizeof sized / sizeof sized[0]);
return 0;
}counts[0] = 4
counts[1] = 7
counts[2] = 0
counts[3] = 0
counts[4] = 0
counts[5] = 0
cleared[3] = 0
sized has 3 elementsThat zero-filling is a genuine guarantee of the language, not a coincidence, and it is the reason = {0}
is such a common sight. An array declared with no initialiser at all is a different story: like any
uninitialised local, its contents are indeterminate, and reading them before writing is undefined
behaviour.
Measuring an Array with sizeof
sizeof on an array gives the total bytes it occupies, and dividing by the size of one element gives the
count. That idiom appears constantly in C, because it is the only way to ask an array how long it is.
#include <stdio.h>
int main(void) {
int scores[5] = {88, 92, 79, 95, 84};
printf("bytes in the array: %zu\n", sizeof scores);
printf("bytes in one element: %zu\n", sizeof scores[0]);
printf("elements: %zu\n", sizeof scores / sizeof scores[0]);
return 0;
}bytes in the array: 20
bytes in one element: 4
elements: 5Five ints at four bytes each, on this site's 32-bit target. The 20 would be 20 on almost any
platform because int is very widely 4 bytes, but the division is what makes the code correct
everywhere rather than just here.
The idiom has one hard limit, and it is important: it only works where the compiler can still see the
array's declaration. Pass an array to a function and what arrives is a pointer, at which point sizeof
measures the pointer instead. That is why practically every C function taking an array takes a length
parameter beside it, and the pointers lesson shows exactly what happens.
No Bounds Checking
C does not check that an index is in range. Not at compile time in general, and never at run time.
int scores[5] = {88, 92, 79, 95, 84};
scores[7] = 0; /* seven is past the end - nothing stops this */Reading or writing outside an array is undefined behaviour. On a desktop machine it might crash, it might silently corrupt a different variable that happened to live next door, or it might appear to work for years and then not. In a WebAssembly sandbox like the one on this site, the memory next door is very likely to be readable and writable, so a stray write will usually just quietly damage something instead of announcing itself. That is a reason to be more careful here, not less: the crash you would like to receive as a warning does not arrive.
There is no library function and no compiler flag that makes this go away. Getting indexes right is the
programmer's job in C, and the discipline that does it is simple: derive loop bounds from one source of
truth, keep the length next to the data, and re-read every <= in a loop condition.
A String Is a char Array With a Zero on the End
C has no string type. What it has is a convention: a string is a run of chars, and its end is marked by
a byte with the value zero, written '\0' and called the NUL terminator. Every function that handles
text - printf's %s, everything in <string.h> - works by scanning forward until it finds that byte.
A double-quoted literal in your source builds exactly that, terminator included.
#include <stdio.h>
#include <string.h>
int main(void) {
char word[] = "cargo";
printf("%s\n", word);
printf("characters: %zu\n", strlen(word));
printf("bytes: %zu\n", sizeof word);
printf("first letter: %c\n", word[0]);
printf("byte at index 5 is zero: %d\n", word[5] == '\0');
return 0;
}cargo
characters: 5
bytes: 6
first letter: c
byte at index 5 is zero: 1Five letters, six bytes. char word[] = "cargo"; is shorthand for
char word[6] = {'c', 'a', 'r', 'g', 'o', '\0'}; and the compiler counts for you. strlen reports 5,
because it counts characters before the terminator; sizeof reports 6, because it measures the whole
array. Those two numbers are different, they are different for a reason, and mixing them up is how
buffers get overrun.
Because the terminator is just a byte in the array, you can move it - and the string gets shorter without anything being erased:
#include <stdio.h>
#include <string.h>
int main(void) {
char label[] = "warehouse";
printf("%s\n", label);
label[4] = '\0';
printf("%s\n", label);
printf("length now: %zu\n", strlen(label));
printf("bytes still: %zu\n", sizeof label);
printf("byte after the terminator: %c\n", label[5]);
return 0;
}warehouse
ware
length now: 4
bytes still: 10
byte after the terminator: oWriting one zero byte at index 4 turned "warehouse" into "ware" as far as every string function is
concerned. The o at index 5 is untouched and still perfectly readable - reading it is well defined,
because index 5 is inside the array. The string got shorter; the array did not.
Walking a String Yourself
Because the terminator marks the end, a loop can walk a string without knowing its length in advance.
#include <stdio.h>
int main(void) {
char phrase[] = "wide load";
int spaces = 0;
int letters = 0;
for (int i = 0; phrase[i] != '\0'; i++) {
if (phrase[i] == ' ') {
spaces++;
} else {
letters++;
}
}
printf("%d space(s), %d letter(s)\n", spaces, letters);
return 0;
}1 space(s), 8 letter(s)The loop condition is the whole trick: keep going while the current byte is not zero. This is also
precisely what strlen does internally, which is worth remembering for a practical reason - strlen is
not a free lookup, it is a scan, so calling it inside a loop condition makes that loop do quadratic work.
Compute the length once into a variable and use that.
The string.h Functions
<string.h> provides the standard operations. Each of them relies on the terminator, and each of them
trusts you completely about sizes.
#include <stdio.h>
#include <string.h>
int main(void) {
char buffer[32];
strcpy(buffer, "crate");
printf("%s (%zu)\n", buffer, strlen(buffer));
strcat(buffer, " 12");
printf("%s (%zu)\n", buffer, strlen(buffer));
printf("equal strings compare as %d\n", strcmp("apple", "apple"));
printf("apple before banana: %d\n", strcmp("apple", "banana") < 0);
printf("banana after apple: %d\n", strcmp("banana", "apple") > 0);
return 0;
}crate (5)
crate 12 (8)
equal strings compare as 0
apple before banana: 1
banana after apple: 1strcpy(destination, source) copies the source string, terminator included, over the start of the
destination. strcat(destination, source) finds the destination's terminator and writes the source from
there, so the result is one joined string. strcmp(a, b) returns zero when the two strings are equal, a
negative number when a sorts before b, and a positive number when it sorts after.
Two details about strcmp are worth pinning down. Only the sign is specified - the exact magnitude is
up to the implementation, so compare against zero and never against a particular number. And "sorts
before" means by byte value, not by any human alphabetical rule, so every uppercase letter sorts before
every lowercase one in ASCII.
Also note what strcpy is not: buffer = "crate"; does not work for an array, and a = b between two
char arrays is not a copy. Assignment is for single values; arrays are copied element by element, which
is what strcpy and memcpy do for you.
Why Buffer Overruns Happen
None of those functions takes a size for the destination. strcpy writes until it has copied the
source's terminator, however far past the end of your array that is:
char small[4];
strcpy(small, "overlong"); /* nine bytes written into four bytes of space */Nothing in that code is checked by anyone. small has room for three characters and a terminator;
"overlong" needs nine bytes; the remaining five go into whatever memory follows small. The same
applies to strcat on a buffer that is nearly full, to gets (removed from the language for exactly
this reason), and to any hand-written loop whose bound comes from the source rather than the destination.
This is the buffer overrun, and it is the single most consequential class of bug in C's history. Its effects range from a corrupted value somewhere else in the program, to a crash at a point unrelated to the mistake, to an attacker choosing what your program does next. Its cause is always the same shape: a write whose length was decided by the data rather than by the space available.
You will not find a demonstration of one on this page. There is no output to show, because the behaviour is undefined - whatever a particular run happened to print would be a lie about what the language promises, and in a sandbox where the neighbouring memory is writable the run may well look entirely normal while having quietly destroyed something.
What to do instead:
- Know the size of every destination and honour it.
sizeof bufferwhere the array is in scope; a size parameter passed alongside the pointer where it is not. - Leave room for the terminator. A buffer of
nbytes holds at mostn - 1characters. - Prefer the bounded functions.
snprintf(buffer, sizeof buffer, "%s", source)will never write more thansizeof bufferbytes and always terminates what it writes, which makes it the most reliable of the copy-and-format tools.strncpyandstrncattake a length too, but their edge cases are genuinely awkward -strncpymay leave the result unterminated - so read their documentation before trusting them. - Never read into a fixed buffer without a bound.
fgets(buffer, sizeof buffer, stdin)is bounded;scanf("%s", buffer)is not.
Arrays of More Than One Dimension
An array's element type can itself be an array, which gives you a grid. The rows are laid out one after another in memory.
#include <stdio.h>
int main(void) {
int grid[2][3] = {{1, 2, 3}, {4, 5, 6}};
for (int row = 0; row < 2; row++) {
for (int col = 0; col < 3; col++) {
printf("%d", grid[row][col]);
}
printf("\n");
}
printf("grid[1][2] = %d\n", grid[1][2]);
printf("total bytes: %zu\n", sizeof grid);
return 0;
}123
456
grid[1][2] = 6
total bytes: 24grid[1][2] is row 1, column 2 - the sixth value, because both indexes count from zero. Six ints at
four bytes each is 24 bytes, with nothing extra in between.
The same idea gives you a table of strings: an array of char arrays, where the second dimension is the
room allowed for each one.
#include <stdio.h>
#include <string.h>
int main(void) {
char names[3][8] = {"ana", "bruno", "chidi"};
for (int i = 0; i < 3; i++) {
printf("%d: %s (%zu letters)\n", i, names[i], strlen(names[i]));
}
return 0;
}0: ana (3 letters)
1: bruno (5 letters)
2: chidi (5 letters)Every row is eight bytes whether it needs them or not, and the unused bytes in each row are zero-filled, so each row is a properly terminated string. The eight is a decision you are making about the longest name you will accept - a name needing eight characters plus a terminator would not fit, and putting one there is the overrun problem again.
A Worked Example
One pass over a string that counts its vowels and builds an uppercase copy in a second buffer.
#include <stdio.h>
#include <ctype.h>
#include <string.h>
int main(void) {
char source[] = "loading dock";
char shouted[32];
int vowels = 0;
size_t length = strlen(source);
for (size_t i = 0; i < length; i++) {
char c = source[i];
shouted[i] = (char) toupper((unsigned char) c);
if (c == 'a' || c == 'e' || c == 'i' || c == 'o' || c == 'u') {
vowels++;
}
}
shouted[length] = '\0';
printf("source: %s\n", source);
printf("shouted: %s\n", shouted);
printf("length: %zu\n", length);
printf("vowels: %d\n", vowels);
return 0;
}source: loading dock
shouted: LOADING DOCK
length: 12
vowels: 4Five things in that program are deliberate.
strlen is called once, into length, rather than in the loop condition - so the scan happens once
rather than on every pass.
The loop counter is a size_t, matching what strlen returns. Comparing a signed int against an
unsigned size_t is a well-known source of warnings and of genuinely surprising behaviour when the
signed value is negative, and using the same type on both sides removes the question.
shouted has room for 32 bytes, and source is 12 characters, so the copy fits with room to spare. In
a program where the source length is not visible on the page, that comparison has to be made in code
before the loop rather than trusted.
shouted[length] = '\0' is not optional. The loop wrote 12 characters and stopped; without that line
shouted would have no terminator and printf("%s", shouted) would read on past it. This is the single
most common way a hand-built string goes wrong.
toupper comes from <ctype.h> and takes an int. The (unsigned char) cast before passing is the
standard defensive move: a plain char may be signed, a negative value other than EOF is not something
toupper is defined for, and the cast makes the value non-negative first. The (char) cast on the way
back just stores the result where it belongs.
Common Mistakes
Using the array's length as an index. scores[5] on a five-element array is out of bounds. Valid
indexes stop one below the count.
Confusing strlen with sizeof. strlen counts characters up to the terminator and scans to find
it. sizeof counts bytes of storage and is answered at compile time. For char word[] = "cargo" they are
5 and 6. Allocate with sizeof, iterate with strlen.
Forgetting the terminator when sizing a buffer. A name of up to 20 characters needs char name[21].
Off-by-one here is an overrun waiting for the longest possible input.
Assigning to an array. buffer = "text"; and a = b between arrays are both errors or copies of a
pointer rather than of the data. Use strcpy for strings and memcpy for anything else.
Comparing strings with ==. if (name == "ana") compares two addresses, not two sets of characters,
and is almost never what you want. Use strcmp(name, "ana") == 0.
Calling sizeof on an array parameter. Inside void f(int data[]), sizeof data is the size of a
pointer. Pass the length as a separate argument.
Modifying a string literal. char *text = "cargo"; text[0] = 'C'; is undefined behaviour - a literal
may live in read-only memory. char text[] = "cargo"; makes a writable copy, and that version is fine to
modify. Declaring pointers to literals as const char * makes the compiler enforce the distinction.
Assuming an uninitialised array is zero. int counts[6]; with no initialiser has indeterminate
contents. int counts[6] = {0}; does not.
Next Steps
In the C playground, declare a char array holding a short sentence and write a
loop that prints each character with its numeric value on its own line. Then write a second loop that
copies it backwards into another buffer, remembering the terminator - if the output looks right but has
extra characters glued on the end, you have just found out what the terminator is for.
The next lesson is pointers, and it explains what has really been happening every time an array was passed to a function.
Write it yourself
Reading about code and writing it are different skills. These exercises practise exactly what this lesson covered; they run in this tab and need no account.