Skip to main content
C · lesson 9 of 25
C beginner Lesson 9 of 25

Arrays in C

C arrays in practice: initialization, bounds bugs caught by AddressSanitizer, decay to pointers, 2D and variable-length arrays, and qsort.

By SkillByExample · Updated

Declaring and Initializing Arrays

An array is a fixed-size run of same-typed values stored next to each other in memory. C offers several ways to initialize one:

#include <stdio.h>

int main(void) {
    int primes[6] = {2, 3, 5, 7, 11, 13};
    int data[6] = {1, 2, 3};            /* the rest become 0 */
    double temps[] = {36.5, 37.1, 38.2, 36.9};   /* size from initializer */
    int days_in_month[13] = { [1] = 31, [2] = 28, [12] = 31 };  /* C99 */

    size_t n = sizeof temps / sizeof temps[0];
    printf("temps has %zu elements\n", n);

    printf("primes:");
    for (int i = 0; i < 6; i++) printf(" %d", primes[i]);
    printf("\ndata:  ");
    for (int i = 0; i < 6; i++) printf(" %d", data[i]);
    printf("\ndays_in_month[1]=%d [2]=%d [3]=%d [12]=%d\n",
           days_in_month[1], days_in_month[2], days_in_month[3], days_in_month[12]);
    return 0;
}
$ gcc -Wall -Wextra -std=c17 -o init init.c
$ ./init
temps has 4 elements
primes: 2 3 5 7 11 13
data:   1 2 3 0 0 0
days_in_month[1]=31 [2]=28 [3]=0 [12]=31

Once an initializer is present, every element it doesn’t mention is zeroed. That’s why data’s tail and days_in_month[3] are 0, and why int zeroes[100] = {0}; clears a whole array. Designated initializers ([12] = 31) set specific slots by index. Without any initializer, a local array holds garbage.

Iterating Over Arrays

Loop from 0 to n - 1. A function receiving an array also needs its length, because the array itself doesn’t carry one:

#include <stdio.h>

void print_array(const int *arr, size_t n) {
    for (size_t i = 0; i < n; i++) {
        printf("%d%s", arr[i], i + 1 < n ? ", " : "\n");
    }
}

int find_max(const int *arr, size_t n) {
    int max = arr[0];
    for (size_t i = 1; i < n; i++) {
        if (arr[i] > max) max = arr[i];
    }
    return max;
}

int main(void) {
    int nums[] = {42, 17, 93, 5, 68, 31};
    size_t len = sizeof nums / sizeof nums[0];
    print_array(nums, len);
    printf("Max: %d\n", find_max(nums, len));
    return 0;
}
$ gcc -Wall -Wextra -std=c17 -o iterate iterate.c
$ ./iterate
42, 17, 93, 5, 68, 31
Max: 93

size_t is the natural type for lengths and indices: it’s what sizeof returns, and comparing it with another size_t avoids signed/unsigned warnings. The separator trick i + 1 < n ? ", " : "\n" prints a comma after every element except the last.

Going Out of Bounds

C never checks an index. This loop reads one element too many, a classic off-by-one:

#include <stdio.h>

int main(void) {
    int scores[5] = {90, 85, 77, 68, 95};
    int total = 0;
    for (int i = 0; i <= 5; i++) {   /* off by one: <= should be < */
        total += scores[i];
    }
    printf("total = %d\n", total);
    return 0;
}
$ gcc -Wall -Wextra -std=c17 -o bounds bounds.c
$ ./bounds
total = 415
$ gcc -Wall -Wextra -std=c17 -g -fsanitize=address -o bounds bounds.c
$ ./bounds
=================================================================
==797==ERROR: AddressSanitizer: stack-buffer-overflow on address 0x7f5514c00034 at pc 0x0000004013a2 bp 0x7ffd82341010 sp 0x7ffd82341008
READ of size 4 at 0x7f5514c00034 thread T0
    #0 0x4013a1 in main /w/arrays/bounds.c:7

The normal build printed 415, which is exactly the correct sum of the five scores. The out-of-bounds read of scores[5] happened to find a zero on the stack, so the bug is invisible. Change the surrounding code, the compiler or the optimization level and it may show up as a wrong total or a crash. Building with -fsanitize=address (ASan) turns the silent read into an immediate error pointing at line 7. The full report (trimmed here) goes on to describe the stack frame. ASan’s addresses change on every run.

Array Decay to Pointer

When you pass an array to a function, only the address of its first element is passed. This is called decay, and it loses the size:

#include <stdio.h>

void size_demo(int arr[], int n) {
    printf("in function: sizeof(arr) = %zu, n = %d\n", sizeof(arr), n);
}

int main(void) {
    int data[10] = {0};
    printf("in main:     sizeof(data) = %zu\n", sizeof(data));
    size_demo(data, 10);
    printf("data == &data[0]: %s\n", data == &data[0] ? "yes" : "no");
    return 0;
}
$ gcc -Wall -Wextra -std=c17 -o decay decay.c
decay.c: In function 'size_demo':
decay.c:4:62: warning: 'sizeof' on array function parameter 'arr' will return size of 'int *' [-Wsizeof-array-argument]
    4 |     printf("in function: sizeof(arr) = %zu, n = %d\n", sizeof(arr), n);
      |                                                              ^
decay.c:3:20: note: declared here
    3 | void size_demo(int arr[], int n) {
      |                ~~~~^~~~~
$ ./decay
in main:     sizeof(data) = 40
in function: sizeof(arr) = 8, n = 10
data == &data[0]: yes

In main, data is a real array of 10 ints (40 bytes). Inside the function, arr is an int * (8 bytes), despite the [] in its declaration. GCC warns about exactly this mistake. void f(int arr[]), void f(int *arr) and void f(int arr[10]) all declare the same function, and the 10 in the last one is ignored.

2D Arrays

A 2D array is stored in row-major order: all of row 0, then all of row 1, one contiguous block:

#include <stdio.h>

#define ROWS 3
#define COLS 4

void print_matrix(int mat[][COLS], int rows) {   /* column count is required */
    for (int r = 0; r < rows; r++) {
        for (int c = 0; c < COLS; c++) printf("%4d", mat[r][c]);
        printf("\n");
    }
}

int main(void) {
    int m[ROWS][COLS] = {
        {1,  2,  3,  4},
        {5,  6,  7,  8},
        {9, 10, 11, 12}
    };
    print_matrix(m, ROWS);

    int *flat = &m[0][0];   /* the same memory, viewed as one long row */
    printf("flat[6] = %d (m[1][2] = %d)\n", flat[6], m[1][2]);
    printf("row stride = %td bytes\n", (char *)&m[1][0] - (char *)&m[0][0]);
    return 0;
}
$ gcc -Wall -Wextra -std=c17 -o matrix matrix.c
$ ./matrix
   1   2   3   4
   5   6   7   8
   9  10  11  12
flat[6] = 7 (m[1][2] = 7)
row stride = 16 bytes

m[1][2] sits at flat position 1 * COLS + 2 = 6, and one row is 4 ints = 16 bytes. That stride is why a function parameter must spell out every dimension except the first. Without COLS, the compiler couldn’t turn mat[r][c] into an address. Looping row by row walks memory in order, which is why it’s much faster on large matrices than going column by column.

Dynamic 2D Arrays

When dimensions come from input, allocate one flat block and compute the index yourself:

#include <stdio.h>
#include <stdlib.h>

#define AT(m, cols, r, c) (m)[(r) * (cols) + (c)]

int main(int argc, char *argv[]) {
    int rows = argc > 2 ? atoi(argv[1]) : 3;
    int cols = argc > 2 ? atoi(argv[2]) : 4;

    int *m = malloc((size_t)rows * cols * sizeof *m);
    if (!m) return 1;

    for (int r = 0; r < rows; r++)
        for (int c = 0; c < cols; c++)
            AT(m, cols, r, c) = r * 10 + c;

    for (int r = 0; r < rows; r++) {
        for (int c = 0; c < cols; c++) printf("%4d", AT(m, cols, r, c));
        printf("\n");
    }
    free(m);
    return 0;
}
$ gcc -Wall -Wextra -std=c17 -o flat flat.c
$ ./flat
   0   1   2   3
  10  11  12  13
  20  21  22  23
$ ./flat 2 6
   0   1   2   3   4   5
  10  11  12  13  14  15

The same code handled a 3×4 and a 2×6 matrix, sizes known only at runtime. This is the same row-major formula the compiler uses for m[r][c], written out by hand. The (size_t) cast makes the size multiplication happen in size_t, so a big rows * cols can’t overflow int first.

Variable-Length Arrays (VLAs)

C99 lets a local array’s size come from a runtime value:

#include <stdio.h>

void squares(int n) {
    int arr[n];                      /* size decided at runtime */
    for (int i = 0; i < n; i++) arr[i] = i * i;
    for (int i = 0; i < n; i++) printf("%d ", arr[i]);
    printf("(sizeof arr = %zu)\n", sizeof arr);
}

int main(void) {
    squares(5);
    squares(8);
    return 0;
}
$ gcc -Wall -Wextra -std=c17 -o vla vla.c
$ ./vla
0 1 4 9 16 (sizeof arr = 20)
0 1 4 9 16 25 36 49 (sizeof arr = 32)
$ gcc -Wall -Wextra -std=c17 -Wvla -o vla vla.c
vla.c: In function 'squares':
vla.c:4:5: warning: ISO C90 forbids variable length array 'arr' [-Wvla]
    4 |     int arr[n];                      /* size decided at runtime */
      |     ^~~

For a VLA, sizeof is computed at runtime: 20 bytes, then 32. It’s the one case where sizeof isn’t a compile-time constant. VLAs live on the stack, and a large n overflows it with no error you can check for. They became optional in C11, and MSVC never supported them. Many projects, including the Linux kernel, ban them with -Wvla. For anything large or driven by input, use malloc.

Arrays of Strings

There are two different shapes for a list of strings: an array of pointers to string literals, or a 2D array of characters:

#include <stdio.h>

int main(void) {
    const char *days[] = {"Mon", "Tue", "Wednesday"};  /* pointers to literals */
    char names[3][8] = {"Alice", "Bob", "Carol"};      /* real 2D char array */

    for (int i = 0; i < 3; i++) printf("%-9s|%s\n", days[i], names[i]);

    names[1][0] = 'R';             /* fine: names owns its bytes */
    printf("names[1] is now %s\n", names[1]);

    printf("sizeof days  = %zu (3 pointers)\n", sizeof days);
    printf("sizeof names = %zu (3 x 8 chars)\n", sizeof names);
    return 0;
}
$ gcc -Wall -Wextra -std=c17 -o strings strings.c
$ ./strings
Mon      |Alice
Tue      |Bob
Wednesday|Carol
names[1] is now Rob
sizeof days  = 24 (3 pointers)
sizeof names = 24 (3 x 8 chars)

Both happen to be 24 bytes, for different reasons. days holds three 8-byte pointers, and the text lives elsewhere, in read-only memory, so "Wednesday" costs no extra slot space. names holds the characters themselves: every row is 8 bytes whether it needs them or not, and each row can be edited. Trying to edit a literal through days fails to compile, thanks to the const:

$ gcc -Wall -Wextra -std=c17 -o strings_bad strings_bad.c
strings_bad.c: In function 'main':
strings_bad.c:5:16: error: assignment of read-only location '*days[0]'
    5 |     days[0][0] = 'm';              /* literals are read-only */
      |                ^

Without the const, the same line would compile and then crash at runtime, since string literals are stored in read-only memory. Always declare pointers to literals as const char *.

Sorting with qsort

qsort sorts any array. You give it the element size and a comparison function that returns negative, zero or positive:

#include <stdio.h>
#include <stdlib.h>

int compare_int(const void *a, const void *b) {
    int ia = *(const int *)a;
    int ib = *(const int *)b;
    return (ia > ib) - (ia < ib);   /* -1, 0 or 1, no overflow */
}

int compare_desc(const void *a, const void *b) {
    return compare_int(b, a);       /* swapped arguments reverse the order */
}

void print(const char *label, const int *arr, size_t n) {
    printf("%s", label);
    for (size_t i = 0; i < n; i++) printf(" %d", arr[i]);
    printf("\n");
}

int main(void) {
    int arr[] = {64, 25, 12, 22, 11};
    size_t n = sizeof arr / sizeof arr[0];
    qsort(arr, n, sizeof arr[0], compare_int);
    print("ascending: ", arr, n);
    qsort(arr, n, sizeof arr[0], compare_desc);
    print("descending:", arr, n);
    return 0;
}
$ gcc -Wall -Wextra -std=c17 -o sort sort.c
$ ./sort
ascending:  11 12 22 25 64
descending: 64 25 22 12 11

qsort hands the comparator void * pointers because it knows nothing about the element type, so the comparator casts them back. The common shortcut return *(const int *)a - *(const int *)b; looks equivalent, but it isn’t. Here is that version sorting {INT_MAX, -10, INT_MIN, 5}:

$ gcc -Wall -Wextra -std=c17 -o subtract_cmp subtract_cmp.c
$ ./subtract_cmp
5 2147483647 -2147483648 -10 

The result isn’t sorted, and nothing warned. INT_MAX - (-10) overflows int, which is undefined behavior, and in practice wraps to a negative number that tells qsort the wrong order. The (a > b) - (a < b) form never overflows.

Practice

  1. Write reverse(int *arr, size_t n) that reverses an array in place. Test it on both an odd and an even length.
Solution
#include <stdio.h>

void reverse(int *arr, size_t n) {
    for (size_t i = 0, j = n - 1; i < j; i++, j--) {
        int tmp = arr[i];
        arr[i] = arr[j];
        arr[j] = tmp;
    }
}

int main(void) {
    int a[] = {1, 2, 3, 4, 5};
    int b[] = {10, 20, 30, 40};
    reverse(a, 5);
    reverse(b, 4);
    for (int i = 0; i < 5; i++) printf("%d ", a[i]);
    printf("| ");
    for (int i = 0; i < 4; i++) printf("%d ", b[i]);
    printf("\n");
    return 0;
}
$ gcc -Wall -Wextra -std=c17 -o p1 p1.c
$ ./p1
5 4 3 2 1 | 40 30 20 10 

Two indices walk toward each other and stop when they meet. With an odd length, the middle element stays put. With an even length, they cross without touching the same slot. Beware n == 0: n - 1 on a size_t wraps to a huge number, so a production version should return early for empty arrays.

  1. Count how many times each die face (1-6) appears in {3, 6, 1, 6, 2, 6, 3, 5, 1, 6}, using the face value as an array index.
Solution
#include <stdio.h>

int main(void) {
    int rolls[] = {3, 6, 1, 6, 2, 6, 3, 5, 1, 6};
    int counts[7] = {0};              /* index 1..6, slot 0 unused */
    size_t n = sizeof rolls / sizeof rolls[0];

    for (size_t i = 0; i < n; i++) counts[rolls[i]]++;
    for (int face = 1; face <= 6; face++) {
        printf("%d: %d\n", face, counts[face]);
    }
    return 0;
}
$ gcc -Wall -Wextra -std=c17 -o p2 p2.c
$ ./p2
1: 2
2: 1
3: 2
4: 0
5: 1
6: 4

Sizing counts as 7 wastes slot 0 but lets the face value be the index directly. A roll of 7 in the input would write past the end, silently. Real code validates the value before using it as an index.

  1. Transpose the 2×3 matrix {{1, 2, 3}, {4, 5, 6}} into a 3×2 matrix.
Solution
#include <stdio.h>

int main(void) {
    int m[2][3] = {{1, 2, 3}, {4, 5, 6}};
    int t[3][2];

    for (int r = 0; r < 2; r++)
        for (int c = 0; c < 3; c++)
            t[c][r] = m[r][c];

    for (int r = 0; r < 3; r++) {
        for (int c = 0; c < 2; c++) printf("%3d", t[r][c]);
        printf("\n");
    }
    return 0;
}
$ gcc -Wall -Wextra -std=c17 -o p3 p3.c
$ ./p3
  1  4
  2  5
  3  6

Swapping the indices, t[c][r] = m[r][c], turns each row of m into a column of t. The two arrays have different shapes, so a non-square matrix can’t be transposed in place this simply.

  1. Rewrite size_demo so the parameter list makes the length relationship explicit, using C99’s int arr[n] syntax.
Solution
#include <stdio.h>

void size_demo(size_t n, int arr[n]) {
    printf("n = %zu, first = %d, last = %d\n", n, arr[0], arr[n - 1]);
}

int main(void) {
    int data[] = {4, 8, 15, 16, 23, 42};
    size_demo(sizeof data / sizeof data[0], data);
    return 0;
}
$ gcc -Wall -Wextra -std=c17 -o p4 p4.c
$ ./p4
n = 6, first = 4, last = 42

n must come first so it’s in scope when arr[n] is declared. arr is still just a pointer, and nothing checks the length at runtime. What the syntax adds is documentation, plus more information for newer GCC versions’ out-of-bounds warnings.

Frequently Asked Questions

Why does C not check array bounds?
C prioritizes performance and trusts the programmer, so it never inserts bounds checks on array access. Reading or writing past the end is undefined behavior: the program may appear to work, return garbage, or corrupt other data. Compile with -fsanitize=address during development to catch out-of-bounds accesses at runtime.
What does it mean for an array to 'decay' to a pointer?
In most expressions, including when passed to a function, an array is automatically converted to a pointer to its first element. The function then receives only an address, so sizeof on the parameter gives the pointer size (8 bytes on 64-bit systems), not the array size. Always pass the length as a separate argument.
Are variable-length arrays (VLAs) safe?
VLAs live on the stack, so a large or attacker-controlled size can overflow the stack with no way to recover or detect failure. They also became optional in C11. They are fine for small, bounded sizes; for anything large or driven by input, use malloc, which can report failure by returning NULL.
Why shouldn't a qsort comparator return a - b?
Subtracting two ints can overflow when they have opposite signs and large magnitudes, for example INT_MAX - (-10). Signed overflow is undefined behavior and in practice flips the sign of the result, so qsort gets the wrong ordering. Return (a > b) - (a < b) instead, which always yields -1, 0 or 1.