We’re Officially Live β€” Lifetime Founding Membership Available for a Limited Time
Free preview

Pointer Arithmetic & Arrays

Why arr[i] is literally *(arr + i), how pointer math scales by type size, and the array-to-pointer decay that loses your size the moment you call a function.

25 min read

In this lesson:


Arrays and pointers are deeply related

An array name, used in an expression, decays to a pointer to its first element. That's why this works:

int arr[4] = {10, 20, 30, 40};
int *p = arr;        // no &needed: 'arr' becomes &arr[0]
 
printf("%d\n", *p);      // 10
printf("%d\n", arr[0]);  // 10 β€” same thing

The indexing operator is defined in terms of pointer arithmetic. The compiler literally rewrites arr[i] as *(arr + i):

arr[2]      // what you write
*(arr + 2)  // what the compiler does
2[arr]      // legal (!) but cursed β€” *(2 + arr) is the same expression

That last line compiles and works, because addition commutes. Don't write it, but it proves that [] is pure pointer math underneath.


Pointer arithmetic scales by type

Here's the part that surprises beginners: p + 1 does not add 1 to the address. It adds sizeof(*p), one element, not one byte.

int arr[4] = {10, 20, 30, 40};
int *p = arr;        // suppose p == 0x1000
 
p + 0   // 0x1000  β†’ arr[0]
p + 1   // 0x1004  β†’ arr[1]   (int is 4 bytes, so +4 bytes)
p + 2   // 0x1008  β†’ arr[2]
        p     p+1   p+2   p+3
        β”‚      β”‚     β”‚     β”‚
        β–Ό      β–Ό     β–Ό     β–Ό
      β”Œβ”€β”€β”€β”€β”¬β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”
      β”‚ 10 β”‚ 20 β”‚ 30 β”‚ 40 β”‚   each cell = 4 bytes (int)
      β””β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”˜
      0x1000     0x1008

This is why the pointer's type matters: a char * advances by 1 byte, an int * by 4, a struct foo * by sizeof(struct foo). The compiler does the scaling for you, so you always think in elements.

Subtraction works too: &arr[3] - &arr[0] is 3 (the number of elements between them), not 12.


Iterating with pointers

Anything you can do with an index, you can do by walking a pointer. Two equivalent loops:

int arr[4] = {10, 20, 30, 40};
 
// index style
for (int i = 0; i < 4; i++) {
    printf("%d ", arr[i]);
}
 
// pointer style β€” walk from the start to one-past-the-end
for (int *p = arr; p < arr + 4; p++) {
    printf("%d ", *p);
}

The pointer style uses arr + 4, a pointer to one past the last element, as the stop condition. C explicitly allows you to compute and compare that one-past pointer (it's a valid loop sentinel), but dereferencing it is undefined behavior. More on that below.


Decay: the size disappears at the function boundary

While arr is a real array in its defining scope, sizeof knows its full size. The instant you pass it to a function, it decays to a bare pointer and that knowledge is gone:

void f(int a[]) {              // identical to: void f(int *a)
    printf("%zu\n", sizeof(a)); // 8 β€” size of a POINTER, not the array
}
 
int main(void) {
    int arr[4] = {1, 2, 3, 4};
    printf("%zu\n", sizeof(arr)); // 16 β€” 4 ints Γ— 4 bytes
    f(arr);                        // decays to &arr[0]; size info lost
    return 0;
}

Even though the parameter is written int a[], the compiler treats it as int *a. The [] is cosmetic. This is the reason every function that takes an array must also take its length:

int sum(const int *arr, size_t n) {   // length passed explicitly
    int total = 0;
    for (size_t i = 0; i < n; i++) total += arr[i];
    return total;
}

sizeof(arr) / sizeof(arr[0]) to get a length only works in the scope where arr is a true array, never inside a function that received it as a parameter.


Arrays are not pointers (a few real differences)

Decay makes them feel interchangeable, but they aren't the same type:

True array int arr[4] Pointer int *p
sizeof 16 (whole array) 8 (the pointer)
&arr type int (*)[4] (pointer to array) int **
Assignable? No, arr = ... is illegal Yes, p = ... fine
Storage the elements themselves just an address

A useful mental model: an array is its elements; a pointer merely refers to elements somewhere else.


The one-past-the-end rule

C defines exactly one out-of-bounds pointer you're allowed to form: the address one element past the end of an array. You may compute it and compare against it (it's the canonical loop terminator), but you may not dereference it.

int arr[4];
int *end = arr + 4;     // OK to form and compare
for (int *p = arr; p < end; p++) { ... }   // fine
 
int *bad = arr + 5;     // UB to even *form* this pointer
int x = *(arr + 4);     // UB β€” dereferencing one-past-the-end

Going further than one-past, forming arr + 5 or arr - 1, is undefined behavior even if you never dereference it. Optimizers exploit this, so "it didn't crash" doesn't mean it's correct.


Gotchas


TL;DR

This is just the start

Sign up free to track progress on this lesson, get coding problems with the Run button, and unlock the full interview Q&A.

More in C Programming

The C Compilation PipelinePointers & Addresses
Dynamic Memory: malloc & free
Stack vs Heap
Structs, Unions & Bitfields
Bit Manipulation & Masks
Pointer Arithmetic & Arrays | EmbeddedPrep.io