What an array is in general, and how it differs from a dynamic array or a linked list, is explained on data structures. This page assumes you know pointers, because in C the two are inseparable.

Declaring and initializing arrays

int readings[5]; reserves room for five int values in one block of memory, numbered 0 to 4. The size must be known when the array is created and cannot change afterward; a list that grows needs dynamic memory. An initializer list fills the array from the start; elements it does not mention become 0. Leave out the size and the compiler counts the list, and since C99 a designated initializer sets specific positions:

#include <stdio.h>

int main(void)
{
    int readings[5] = {120, 95, 130};          /* the rest becomes 0 */
    int hours[] = {0, 6, 12, 18};              /* size taken from the list */
    int flags[8] = {[2] = 1, [7] = 1};         /* designated (C99) */

    printf("sizeof readings = %zu bytes, %zu elements\n",
           sizeof readings, sizeof readings / sizeof readings[0]);
    printf("hours has %zu elements\n", sizeof hours / sizeof hours[0]);
    for (int i = 0; i < 5; i++) {
        printf("readings[%d] = %d\n", i, readings[i]);
    }
    for (int i = 0; i < 8; i++) {
        printf("%d", flags[i]);
    }
    printf("\n");
    return 0;
}
sizeof readings = 20 bytes, 5 elements
hours has 4 elements
readings[0] = 120
readings[1] = 95
readings[2] = 130
readings[3] = 0
readings[4] = 0
00100001

sizeof readings / sizeof readings[0], the total size divided by the size of one element, is the standard way to get the number of elements; many projects wrap it in a macro such as ARRAY_SIZE(a). A local array without an initializer is not zeroed: its elements hold whatever was in memory before, so write = {0} when you need a clean start.

Arrays cannot be assigned or compared as a whole. yesterday = today; does not compile, and today == yesterday compares two addresses, not the contents. Copy with a loop or with memcpy:

#include <stdio.h>

int main(void)
{
    int today[3] = {120, 95, 130};
    int yesterday[3];

    yesterday = today;
    printf("%d\n", yesterday[0]);
    return 0;
}
example.c:8:15: error: assignment to expression with array type
#include <stdio.h>
#include <string.h>

int main(void)
{
    int today[3] = {120, 95, 130};
    int yesterday[3];

    memcpy(yesterday, today, sizeof today);
    today[0] = 999;
    printf("today[0] = %d, yesterday[0] = %d\n", today[0], yesterday[0]);
    return 0;
}
today[0] = 999, yesterday[0] = 120

C never checks bounds

Indexing an array compiles to a simple address calculation: start address plus index times element size. Nothing checks that the index is inside the array. An out-of-bounds access is a read or write through an index or pointer that lies outside the object it belongs to. It is undefined behavior: the program reads or overwrites whatever happens to be next in memory, such as another variable, the bookkeeping of the heap or a return address.

#include <stdio.h>

int main(void)
{
    int readings[3] = {120, 95, 130};
    int index = 3;               /* one past the last valid index */

    printf("reading: %d\n", readings[index]);
    return 0;
}
SUMMARY: AddressSanitizer: stack-buffer-overflow example.c:8 in main

Built normally (gcc 12, -O0) on our test machine, a 64-bit Linux machine (ARM64), the same program printed reading: 3: the value of index, which happened to be stored right after the array. No crash, no warning, a believable number. A different compiler, optimization level or machine gives a different answer. With the sanitizers on, the program stops at the bad read instead. Before AddressSanitizer's report, UndefinedBehaviorSanitizer prints a line that names the mistake exactly:

example.c:8:37: runtime error: index 3 out of bounds for type 'int [3]'

The compiler sees some cases too, but only when it can work out the index: with -O2, gcc's -Warray-bounds reports a constant index past the end (array subscript 3 is above array bounds of ‘int[3]’); at -O0 it says nothing. Off-by-one loops are the most common cause; see control flow and loops.

Array decay

In almost every expression, the name of an array is converted into a pointer to its first element. This array decay is why int *p = readings; works without an &, and why p then equals &readings[0]. There are three exceptions: the operand of sizeof (which measures the whole array), the operand of & (which gives a pointer to the whole array, with the same address but a different type) and a string literal used to initialize a char array. (C23 adds typeof as a fourth.)

Decay also happens when an array is passed to a function. The function receives only the pointer, so it cannot know how many elements there are. A parameter written as int values[] or even int values[4] is really int *values, and sizeof on it gives the size of a pointer. gcc warns about exactly this mistake:

#include <stdio.h>

int total(int values[], int count)
{
    int sum = 0;
    for (size_t i = 0; i < sizeof values / sizeof values[0]; i++) {
        sum += values[i];
    }
    (void)count;
    return sum;
}

int main(void)
{
    int readings[4] = {120, 95, 130, 110};
    printf("%d\n", total(readings, 4));
    return 0;
}
example.c:6:35: error: ‘sizeof’ on array function parameter ‘values’ will return size of ‘int *’ [-Werror=sizeof-array-argument]

The fix is the C convention: pass the pointer and the number of elements together, as a size_t, and mark the pointer const when the function only reads:

#include <stddef.h>
#include <stdio.h>

static void show(const int *values, size_t count)
{
    printf("in show: sizeof values = %zu (a pointer)\n", sizeof values);
    for (size_t i = 0; i < count; i++) {
        printf(" %d", values[i]);
    }
    printf("\n");
}

int main(void)
{
    int readings[4] = {120, 95, 130, 110};
    int *p = readings;           /* the array decays to &readings[0] */

    printf("in main: sizeof readings = %zu (the whole array)\n", sizeof readings);
    printf("p == &readings[0]: %d\n", p == &readings[0]);
    show(readings, sizeof readings / sizeof readings[0]);
    return 0;
}
in main: sizeof readings = 16 (the whole array)
p == &readings[0]: 1
in show: sizeof values = 8 (a pointer)
 120 95 130 110

Because the function works on the caller's elements through the pointer, it can also change them: arrays are the one argument that behaves as if it were passed by reference. Decay is also why the length idiom fails on a pointer outside a function parameter, for instance on memory from malloc. gcc and clang catch the plain case with -Wsizeof-pointer-div (part of -Wall), but not a disguised one, for example when sizeof p is first stored in a variable, so keep the count yourself.

Pointer arithmetic

Pointer arithmetic is adding an integer to a pointer, subtracting one, or subtracting two pointers into the same array. It counts in elements, not bytes: if p points at an int, p + 1 points at the next int, 4 bytes further on this machine. The subscript operator is defined in terms of it: a[i] means exactly *(a + i). The difference of two pointers has the type ptrdiff_t, printed with %td.

#include <stddef.h>
#include <stdio.h>

int main(void)
{
    int readings[5] = {120, 95, 130, 110, 105};
    int *p = readings;

    printf("*p       = %d\n", *p);
    printf("*(p + 2) = %d\n", *(p + 2));
    printf("p[2]     = %d\n", p[2]);
    printf("2[p]     = %d\n", 2[p]);

    int *last = &readings[4];
    ptrdiff_t distance = last - p;
    printf("elements between: %td\n", distance);
    printf("bytes between:    %td\n", (char *)last - (char *)p);
    return 0;
}
*p       = 120
*(p + 2) = 130
p[2]     = 130
2[p]     = 130
elements between: 4
bytes between:    16
The array readings with five int elements, 120, 95, 130, 110 and 105, at the illustrative addresses 0x1000, 0x1004, 0x1008, 0x100C and 0x1010. Pointer p points at element 0; p plus 1 points at element 1, four bytes further; p plus 2 at element 2. The position after the last element, readings plus 5 at 0x1014, may be pointed at but not read. [i] 0 1 2 3 4 120 95 130 110 105 end 0x1000 0x1004 0x1008 0x100C 0x1010 0x1014 p p + 1 p + 2 readings + 5 Addresses are illustrative; each int is 4 bytes.
Pointer arithmetic moves by whole elements: p + 1 is 4 bytes further for an int. The position just past the end may be computed and compared, but never read.

The order in 2[p] is legal only because *(2 + p) is the same as *(p + 2); it appears in puzzles, never in real code. The cast to char * in the last line is how you count bytes: a char is one byte by definition.

Walking an array with a pointer

Instead of an index, a loop can move a pointer from the first element to the end. C allows a pointer to the position just past the last element, so readings + 5 is a valid end marker to compare with, as long as it is never dereferenced. Any pointer further out, or before the first element, is undefined behavior even if it is never used.

#include <stdio.h>

int main(void)
{
    int readings[5] = {120, 95, 130, 110, 105};
    const int *end = readings + 5;       /* one past the last element */
    int total = 0;

    for (const int *p = readings; p < end; p++) {
        total += *p;
    }
    printf("total %d\n", total);
    return 0;
}
total 560

Pointer loops show up in older code and in code that walks through strings and buffers, like the telegram scanner on control flow and loops. With today's optimizing compilers the index version is just as fast, and usually easier to read.

*p++ and friends

Increment and dereference are often combined, and the precedence decides what moves: the pointer or the value it points to. *p++ reads the current element and then moves the pointer; (*p)++ increases the element and leaves the pointer alone:

#include <stdio.h>

int main(void)
{
    int kwh[] = {12, 15, 11};
    int *p = kwh;

    int a = *p++;               /* read kwh[0], then move p */
    int b = (*p)++;             /* read kwh[1], then add 1 to it */
    int c = *++p;               /* move p, then read kwh[2] */

    printf("a=%d b=%d c=%d, kwh = {%d, %d, %d}\n", a, b, c, kwh[0], kwh[1], kwh[2]);
    return 0;
}
a=12 b=15 c=11, kwh = {12, 16, 11}

Sentinel values

Since an array does not carry its length, code needs another way to know where the data stops. Besides passing a count, the other classic answer is a sentinel value: a special value that can never be real data, placed after the last element to mark the end. The best-known sentinel in C is the zero byte that ends every string (see strings and buffer overflows); NULL ends the argv array.

#include <stdio.h>

#define END_OF_DATA (-1)

static int sum_until_sentinel(const int *values)
{
    int sum = 0;
    for (const int *p = values; *p != END_OF_DATA; p++) {
        sum += *p;
    }
    return sum;
}

int main(void)
{
    int monday[] = {120, 95, 130, END_OF_DATA};
    int tuesday[] = {110, END_OF_DATA};

    printf("monday %d, tuesday %d\n",
           sum_until_sentinel(monday), sum_until_sentinel(tuesday));
    return 0;
}
monday 345, tuesday 110

A sentinel only works if it is really there. Forget it, and the loop runs on through memory until it happens to find a −1, or crashes. Built normally, this version crashed with a segmentation fault on our test machine; with the sanitizers it stops at the first read past the array:

#include <stdio.h>

static int sum_until_sentinel(const int *values)
{
    int sum = 0;
    for (const int *p = values; *p != -1; p++) {
        sum += *p;
    }
    return sum;
}

int main(void)
{
    int monday[] = {120, 95, 130};          /* sentinel forgotten */
    printf("monday %d\n", sum_until_sentinel(monday));
    return 0;
}
SUMMARY: AddressSanitizer: stack-buffer-overflow example.c:6 in sum_until_sentinel

The sentinel must also be a value that real data can never take. −1 is safe for energy readings that are never negative, but a meter that exports solar power can report negative net values. A separate count avoids both problems, which is why most modern C interfaces take a pointer and a length.

Multidimensional arrays

A multidimensional array in C is an array whose elements are arrays. int usage[3][4] is an array of 3 rows, each an array of 4 int. The rows are stored one after another in a single block (row-major order), so usage[1][0] comes right after usage[0][3]. Passed to a function, the outer array decays to a pointer to its first row, and the function must know the length of a row to find the next one: only the first dimension may be left out of the parameter.

#include <stdio.h>

#define HOMES 3
#define QUARTERS 4

/* The column count must be part of the parameter type. */
static int peak(int usage[][QUARTERS], int homes)
{
    int max = usage[0][0];
    for (int h = 0; h < homes; h++) {
        for (int q = 0; q < QUARTERS; q++) {
            if (usage[h][q] > max) {
                max = usage[h][q];
            }
        }
    }
    return max;
}

int main(void)
{
    int usage[HOMES][QUARTERS] = {
        {12, 15, 11, 14},
        {13, 41, 12, 10},
        {11, 12, 55, 13},
    };

    printf("sizeof usage = %zu, sizeof usage[0] = %zu\n", sizeof usage, sizeof usage[0]);
    printf("row 1 starts %td bytes after row 0\n",
           (char *)usage[1] - (char *)usage[0]);
    printf("peak: %d\n", peak(usage, HOMES));
    return 0;
}
sizeof usage = 48, sizeof usage[0] = 16
row 1 starts 16 bytes after row 0
peak: 55

Loop over the last index in the inner loop, as here: that visits the memory in order, which is faster on any machine with a cache. An array of pointers to separate rows, int *rows[3], looks the same when indexed (rows[1][2]) but is a different structure: the rows can have different lengths and live anywhere in memory.

Variable-length arrays

A variable-length array (VLA) is an array whose size is a run-time value, such as int kwh[quarters]; where quarters is a variable. VLAs were added in C99 and became optional in C11: a compiler that leaves them out defines the macro __STDC_NO_VLA__. (C23 made variably modified types, such as pointers to VLAs, mandatory again, but VLAs on the stack are still optional.) gcc supports them, and -Wall -Wextra do not complain:

#include <stdio.h>

static double average(int count, const int values[count])
{
    long sum = 0;
    for (int i = 0; i < count; i++) {
        sum += values[i];
    }
    return (double)sum / count;
}

int main(void)
{
    int quarters = 4;            /* known only at run time */
    int kwh[quarters];           /* a variable-length array */

    for (int i = 0; i < quarters; i++) {
        kwh[i] = 100 + 10 * i;
    }
    printf("sizeof kwh = %zu\n", sizeof kwh);
    printf("average %.1f\n", average(quarters, kwh));
    return 0;
}
sizeof kwh = 16
average 115.0

sizeof on a VLA is computed at run time. The parameter const int values[count] documents the expected length, but it still decays to a pointer; nothing is checked.

The problem is where a VLA lives: on the stack, with no way to report failure. A size that comes from input, or simply a large one, can run past the end of the stack, and the program crashes or silently overwrites other memory. On a microcontroller with a few kilobytes of stack per task that happens quickly. Many projects and embedded coding standards therefore forbid VLAs, and gcc's -Wvla flags every one. Recorded with that flag:

vla.c:15:5: warning: ISO C90 forbids variable length array ‘kwh’ [-Wvla]
   15 |     int kwh[quarters];           /* a variable-length array */
      |     ^~~

Prefer a fixed maximum size with a checked count, as firmware does with its fixed-size buffers, or allocate on the heap with malloc and check the result (see dynamic memory).