Arrays and Strings

This section documents C23 (ISO/IEC 9899:2024), per ISO/IEC JTC1/SC22/WG14’s freely available working draft N3220, which WG14 documents as differing from the published standard only editorially — the reference these pages are written and verified against.

This content was generated with the assistance of AI and should be verified against the WG14 draft and cppreference.com’s C reference before being relied on in production.

This section’s bibliography lists the reference material consulted while preparing these pages.

An array in C is a contiguous block of objects of one type, and a string is nothing more than a char array whose end is marked by a '\0' byte. There is no array length stored anywhere, no bounds checking, and no string type — which is the source of both C’s speed and most of its security history.

Declaring Arrays and Knowing Their Length

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

int main(void)
{
    int fixed[5];                               // 5 ints, indeterminate values
    int zeroed[5] = { 0 };                      // all five are zero
    int listed[5] = { 1, 2, 3, 4, 5 };
    int inferred[] = { 1, 2, 3 };               // size 3, from the initializer
    int sparse[10] = { [9] = 1 };               // designated: element 9 is 1, rest zero

    // The idiomatic element count -- and the only correct one:
    size_t count = sizeof listed / sizeof listed[0];

    fixed[0] = 1;
    printf("%zu %d %d %d %d\n", count, zeroed[4], listed[4], inferred[2], sparse[9]);
    return 0;
}

Indices run 0 to n-1. Reading or writing array[n] — or any index out of range — is undefined behavior, not an error: no exception, no bounds check, just a corrupted neighbour or a crash later. That is what -fsanitize=address is for.

The sizeof array / sizeof array[0] idiom is worth wrapping, with a caveat:

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

#define ARRAY_COUNT(a) (sizeof (a) / sizeof (a)[0])

// Once an array is passed, its length must travel with it as a parameter.
static int sum(const int *values, size_t n)
{
    int total = 0;
    for (size_t i = 0; i < n; ++i) {
        total += values[i];
    }
    return total;
}

int main(void)
{
    int values[10] = { 1, 2, 3 };

    printf("%zu %d\n", ARRAY_COUNT(values), sum(values, ARRAY_COUNT(values)));   // 10 6
    return 0;
}

ARRAY_COUNT is only valid where the array declaration itself is visible. Inside a function declared as void f(int values[10]), values is a pointer, so the same macro computes sizeof(int *) / sizeof(int) — 2 on LP64. Clang and GCC both catch this one:

warning: 'sizeof (values)' will return the size of the pointer,
         not the array itself [-Wsizeof-pointer-div]
warning: sizeof on array function parameter will return size of 'int *'
         instead of 'int[10]' [-Wsizeof-array-argument]

So once an array has decayed to a pointer, its length must travel as a separate argument — which is exactly what every <string.h> and <stdlib.h> function that takes an n is doing.

Multidimensional Arrays

C has no "2-D array" type — it has arrays of arrays, stored in row-major order with no gaps:

#include <stdio.h>

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

    // Memory layout: 1 2 3 4 5 6 -- rows back to back.
    const int *flat = &grid[0][0];

    printf("%d %d %d\n", grid[1][2], flat[5], *(*(grid + 1) + 2));   // all 6
    printf("sizeof grid = %zu, sizeof grid[0] = %zu, rows = %zu\n",
           sizeof grid, sizeof grid[0], sizeof grid / sizeof grid[0]);
    return 0;
}

grid[i][j] is ((grid + i) + j): the first index selects a row (itself an array of 3 int), the second an element. That is why only the first dimension may be omitted in a parameter — the compiler needs the row width to do the arithmetic:

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

// The row width is part of the type; only the leading dimension may be left out.
static int sum_fixed(int matrix[][3], size_t rows)
{
    int total = 0;
    for (size_t r = 0; r < rows; ++r) {
        for (size_t c = 0; c < 3; ++c) {
            total += matrix[r][c];
        }
    }
    return total;
}

// C99 variably-modified parameter: pass both dimensions and index naturally.
static int sum_any(size_t rows, size_t cols, int matrix[rows][cols])
{
    int total = 0;
    for (size_t r = 0; r < rows; ++r) {
        for (size_t c = 0; c < cols; ++c) {
            total += matrix[r][c];
        }
    }
    return total;
}

int main(void)
{
    int grid[2][3] = { { 1, 2, 3 }, { 4, 5, 6 } };
    printf("%d %d\n", sum_fixed(grid, 2), sum_any(2, 3, grid));
    return 0;
}

Variable-Length Arrays

A VLA’s length is a run-time expression. C99 made them mandatory, C11 optional (__STDC_NO_VLA__ says an implementation lacks them), and C23 keeps them optional while requiring variably-modified pointer types like the int (*)[cols] above.

#include <stdio.h>

static double mean(size_t n, const double values[n])    // a VLA parameter -- fine everywhere
{
    double total = 0.0;
    for (size_t i = 0; i < n; ++i) {
        total += values[i];
    }
    return n ? total / (double)n : 0.0;
}

int main(void)
{
    size_t n = 4;
    double samples[4] = { 1.0, 2.0, 3.0, 4.0 };

    // A local VLA: allocated on the stack, so the size MUST be small and trusted.
    double scratch[n];
    for (size_t i = 0; i < n; ++i) {
        scratch[i] = samples[i] * 2.0;
    }

    printf("%g %g\n", mean(n, samples), mean(n, scratch));
    return 0;
}

Use VLA parameters freely — they are just documentation plus better indexing. Be wary of local VLAs: a length that comes from input is a stack overflow waiting to happen (-Wvla and MISRA both ban them), and malloc gives you a failure you can check. See Dynamic Memory Allocation.

Array-to-Pointer Decay

In almost every expression an array decays to a pointer to its first element. The exceptions are sizeof, alignof, &, and a string literal initializing a char array:

#include <stdio.h>

int main(void)
{
    int values[4] = { 1, 2, 3, 4 };

    int *p = values;                    // decay: same as &values[0]
    int (*whole)[4] = &values;          // pointer to the ARRAY, a different type

    printf("%zu %zu\n", sizeof values, sizeof p);       // 16 8 -- decay loses the size
    printf("%d %d %d\n", *p, p[2], (*whole)[3]);
    printf("%d\n", *(values + 1));      // values[1]: indexing IS pointer arithmetic
    printf("%d\n", 1[values]);          // legal, and a good argument for never doing it
    return 0;
}

C99’s [static n] in a parameter turns "I expect at least n elements" into something the compiler can check and optimize with:

#include <stddef.h>

// "values points to at least 1 int" -- passing nullptr is now diagnosable
// (-Wnonnull), and the compiler may assume the dereference is safe.
static int first(size_t n, const int values[static 1])
{
    (void)n;
    return values[0];
}

int main(void)
{
    int data[3] = { 7, 8, 9 };
    return first(3, data) - 7;
}

Strings Are char Arrays

There is no string type. A "string" is a char array containing a '\0'; every library function finds the end by scanning for that byte:

#include <stdio.h>
#include <string.h>

int main(void)
{
    char writable[] = "hello";          // an array of 6 chars: 'h','e','l','l','o','\0'
    const char *literal = "hello";      // a pointer to a NON-modifiable string literal

    writable[0] = 'H';                  // fine -- writable is our own array
    // literal[0] = 'H';                // UNDEFINED BEHAVIOR: literals may be read-only

    printf("%s %s %zu %zu %zu\n",
           writable, literal,
           strlen(writable),            // 5 -- characters before the NUL
           sizeof writable,             // 6 -- the array, including the NUL
           sizeof literal);             // 8 -- the pointer, on LP64
    return 0;
}

The two lines to internalize: strlen is O(n) (never call it in a loop condition over the same string), and sizeof on a char array includes the NUL while strlen does not. Off-by-one between those two is the canonical C buffer overflow.

String Literals

#include <stdio.h>

int main(void)
{
    const char *joined = "adjacent " "literals " "are concatenated";
    const char *escapes = "tab:\t newline:\n quote:\" backslash:\\ nul-in-middle:\0hidden";
    const char *long_line = "a very long message that "
                            "continues on the next source line";

    printf("%s\n%s\n%s\n", joined, escapes, long_line);
    printf("%zu\n", sizeof "abc");      // 4
    return 0;
}

A string literal has type char[N] (not const char[N], for historical reasons) but writing to it is undefined — so always point at one with const char *. -Wwrite-strings makes the compiler enforce it.

char Arrays vs. Pointers

#include <stdio.h>
#include <string.h>

int main(void)
{
    char buffer[16] = "start";          // 16 bytes of our own storage, copied into
    const char *view = "start";         // 8 bytes pointing at shared, read-only storage

    strcpy(buffer, "changed");          // fits: 8 bytes including NUL
    // strcpy(buffer, "this string is far too long for the buffer");   // overflow -- UB

    printf("%s %s %zu\n", buffer, view, strlen(buffer));
    return 0;
}

Declare char buffer[N] when you need to modify or build a string, and const char * when you only need to read one.

The <string.h> Essentials

Function Does Watch out for

strlen(s)

Length before the NUL

O(n); undefined if s is not NUL-terminated.

strcpy(d, s) / strcat(d, s)

Copy / append, including the NUL

No bounds check at all — the caller guarantees the space.

strncpy(d, s, n)

Copy at most n bytes

Does not NUL-terminate if s is n or longer. Not a safe strcpy.

snprintf(d, n, fmt, …)

Formatted write, always NUL-terminates

The one to use. Returns the length it wanted, so a return >= n means truncation.

strcmp(a, b) / strncmp

Lexicographic compare

Returns <0/0/>0, not a bool. strcmp(a,b) == 0 means equal.

strchr(s, c) / strrchr / strstr(h, n)

Find a character / substring

Return a pointer into the string, or nullptr.

strspn / strcspn / strtok

Span and tokenize

strtok modifies its input and keeps hidden state — avoid it in library code.

memcpy(d, s, n) / memmove

Copy n bytes

memcpy regions must not overlap — use memmove when they might.

memset(d, c, n) / memcmp

Fill / compare bytes

memcmp compares representations, so padding in structs makes it unreliable.

strdup(s) / strndup

Allocate a copy (C23; POSIX before that)

The caller must free the result.

The safe-formatting pattern that replaces strcpy/strcat entirely:

#include <stdio.h>
#include <string.h>

int main(void)
{
    char path[32];
    const char *dir = "/var/log";
    const char *file = "app.log";

    int written = snprintf(path, sizeof path, "%s/%s", dir, file);

    if (written < 0) {
        return 1;                                   // encoding error
    }
    if ((size_t)written >= sizeof path) {
        fprintf(stderr, "path truncated (needed %d bytes)\n", written + 1);
        return 1;                                   // handle it -- do not use the result
    }

    printf("%s (%zu chars)\n", path, strlen(path));
    return 0;
}

C23 adds memset_explicit (a memset the optimizer may not remove, for wiping secrets) and standardizes strdup/strndup. The optional Annex K _s functions (strcpy_s, …) exist but are implemented almost nowhere outside MSVC — see Standard Library Overview.

See Also