Skip to content

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.

C
#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;
}
Output
first: 88
third: 79
last:  84
second is now: 100

Indexes 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:

C
#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;
}
Output
scores[0] = 88
scores[1] = 92
scores[2] = 79
scores[3] = 95
scores[4] = 84
total: 438
average: 87.6

Initialisation 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.

C
#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;
}
Output
counts[0] = 4
counts[1] = 7
counts[2] = 0
counts[3] = 0
counts[4] = 0
counts[5] = 0
cleared[3] = 0
sized has 3 elements

That 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.

C
#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;
}
Output
bytes in the array:   20
bytes in one element: 4
elements:             5

Five 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.

C
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.

C
#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;
}
Output
cargo
characters: 5
bytes:      6
first letter: c
byte at index 5 is zero: 1

Five 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:

C
#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;
}
Output
warehouse
ware
length now: 4
bytes still: 10
byte after the terminator: o

Writing 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.

C
#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;
}
Output
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.

C
#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;
}
Output
crate (5)
crate 12 (8)
equal strings compare as 0
apple before banana: 1
banana after apple:  1

strcpy(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:

C
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 buffer where the array is in scope; a size parameter passed alongside the pointer where it is not.
  • Leave room for the terminator. A buffer of n bytes holds at most n - 1 characters.
  • Prefer the bounded functions. snprintf(buffer, sizeof buffer, "%s", source) will never write more than sizeof buffer bytes and always terminates what it writes, which makes it the most reliable of the copy-and-format tools. strncpy and strncat take a length too, but their edge cases are genuinely awkward - strncpy may 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.

C
#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;
}
Output
123
456
grid[1][2] = 6
total bytes: 24

grid[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.

C
#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;
}
Output
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.

C
#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;
}
Output
source:  loading dock
shouted: LOADING DOCK
length:  12
vowels:  4

Five 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.