Skip to main content
C · lesson 11 of 25
C intermediate Lesson 11 of 25

Pointers in C

Learn C pointers by running them: addresses, pointer arithmetic, pointer-to-pointer, const and void pointers, and function pointer callbacks.

By SkillByExample · Updated

Pointer Basics

A pointer holds an address. & gets the address of a variable, and * follows a pointer to the value it points at:

#include <stdio.h>

int main(void) {
    int x = 42;
    int *p = &x;                 /* & takes the address of x */

    printf("x          = %d\n", x);
    printf("&x         = %p\n", (void *)&x);
    printf("p          = %p\n", (void *)p);
    printf("*p         = %d\n", *p);      /* * follows the address */
    printf("sizeof p   = %zu\n", sizeof p);

    *p = 100;                    /* write through the pointer */
    printf("x is now %d\n", x);

    int *none = NULL;            /* points at nothing */
    if (none == NULL) printf("none is NULL, so don't dereference it\n");
    return 0;
}
$ gcc -Wall -Wextra -std=c17 -o basics basics.c
$ ./basics
x          = 42
&x         = 0x7ffe9593795c
p          = 0x7ffe9593795c
*p         = 42
sizeof p   = 8
x is now 100
none is NULL, so don't dereference it

p and &x print the same address. That’s what it means for p to point at x, and it’s why *p = 100 changed x. A pointer is 8 bytes on a 64-bit system whatever it points to. The (void *) cast is what %p formally expects. Your addresses will differ, and change on every run.

Dereferencing NULL is not a recoverable error:

#include <stdio.h>

int main(void) {
    int *p = NULL;
    printf("about to dereference NULL\n");
    fflush(stdout);
    printf("%d\n", *p);
    return 0;
}
$ gcc -Wall -Wextra -std=c17 -o nullderef nullderef.c
$ ./nullderef
about to dereference NULL
Segmentation fault (core dumped)
$ echo $?
139

GCC compiled this without a warning. The operating system stopped the program with signal 11 (SIGSEGV, exit status 128 + 11 = 139) the moment it touched the null page. The fflush makes sure the first line is actually written before the crash. Always check pointers that can be NULL (from malloc, fopen, search functions) before using them.

Pointer Arithmetic

Adding 1 to a pointer moves it forward by one element, not one byte. The compiler scales by the size of the pointed-to type:

#include <stdio.h>

int main(void) {
    int arr[] = {10, 20, 30, 40, 50};
    int *p = arr;                         /* arr decays to &arr[0] */

    printf("p     = %p -> %d\n", (void *)p, *p);
    printf("p + 1 = %p -> %d\n", (void *)(p + 1), *(p + 1));
    printf("p + 4 = %p -> %d\n", (void *)(p + 4), *(p + 4));

    for (int *q = arr; q < arr + 5; q++) printf("%d ", *q);
    printf("\n");

    int *last = &arr[4];
    printf("last - p = %td elements\n", last - p);
    printf("arr[3] = %d, *(arr + 3) = %d, 3[arr] = %d\n", arr[3], *(arr + 3), 3[arr]);
    return 0;
}
$ gcc -Wall -Wextra -std=c17 -o arith arith.c
$ ./arith
p     = 0x7ffc4c045790 -> 10
p + 1 = 0x7ffc4c045794 -> 20
p + 4 = 0x7ffc4c0457a0 -> 50
10 20 30 40 50 
last - p = 4 elements
arr[3] = 40, *(arr + 3) = 40, 3[arr] = 40

The addresses step by 4 (…790 → …794), the size of an int, and p + 4 is 16 bytes (0x10) past p. Subtracting pointers reverses the scaling, giving a count of elements as a ptrdiff_t (printed with %td). arr[i] is defined as *(arr + i). Addition commutes, so the odd-looking 3[arr] is legal and means the same thing. arr + 5, one past the end, is a valid pointer to compare against, but dereferencing it is not.

Pointer to Pointer

A pointer to a pointer adds one more hop:

#include <stdio.h>

int main(void) {
    int x = 42;
    int *p = &x;        /* pointer to int */
    int **pp = &p;      /* pointer to pointer to int */

    printf("x = %d, *p = %d, **pp = %d\n", x, *p, **pp);
    **pp = 100;         /* two hops: pp -> p -> x */
    printf("x is now %d\n", x);

    int y = 7;
    *pp = &y;           /* one hop: changes where p points */
    printf("*p = %d (p now points at y)\n", *p);
    return 0;
}
$ gcc -Wall -Wextra -std=c17 -o ptrptr ptrptr.c
$ ./ptrptr
x = 42, *p = 42, **pp = 42
x is now 100
*p = 7 (p now points at y)

**pp reaches x. *pp reaches p itself, so assigning to *pp redirected p without mentioning p by name. That second ability is why double pointers exist: a function that must change the caller’s pointer needs its address.

Why a Function Needs char ** to Set Your Pointer

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

void allocate_wrong(char *buf, size_t size) {
    buf = malloc(size);            /* changes the local copy only */
}

void allocate_right(char **buf, size_t size) {
    *buf = malloc(size);           /* changes the caller's pointer */
}

int main(void) {
    char *a = NULL, *b = NULL;
    allocate_wrong(a, 64);
    allocate_right(&b, 64);
    printf("a = %p\n", (void *)a);
    printf("b is %s\n", b ? "allocated" : "NULL");
    if (b) {
        snprintf(b, 64, "hello from the heap");
        printf("b = \"%s\"\n", b);
    }
    free(b);
    return 0;
}
$ gcc -Wall -Wextra -std=c17 -o alloc alloc.c
alloc.c: In function 'allocate_wrong':
alloc.c:4:27: warning: parameter 'buf' set but not used [-Wunused-but-set-parameter]
    4 | void allocate_wrong(char *buf, size_t size) {
      |                     ~~~~~~^~~
$ ./alloc
a = (nil)
b is allocated
b = "hello from the heap"

This is pass-by-value again, one level up. allocate_wrong received a copy of a (a copy of NULL), pointed its copy at new memory, and threw it away. a is still NULL, and the 64 bytes are leaked with no pointer left to free them. The compiler flagged the useless assignment. allocate_right received &b, so *buf = ... wrote into main’s b.

Arrays of Pointers: Rows of Different Lengths

An int ** can index like a 2D array while each row is its own allocation, so rows can differ in length:

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

int main(void) {
    int rows = 3;
    int lengths[] = {1, 3, 5};            /* each row a different length */
    int **tri = malloc(rows * sizeof *tri);
    if (!tri) return 1;

    for (int r = 0; r < rows; r++) {
        tri[r] = malloc(lengths[r] * sizeof *tri[r]);
        for (int c = 0; c < lengths[r]; c++) tri[r][c] = r * 10 + c;
    }
    for (int r = 0; r < rows; r++) {
        for (int c = 0; c < lengths[r]; c++) printf("%3d", tri[r][c]);
        printf("\n");
        free(tri[r]);                     /* free each row... */
    }
    free(tri);                            /* ...then the row table */
    return 0;
}
$ gcc -Wall -Wextra -std=c17 -o rows rows.c
$ ./rows
  0
 10 11 12
 20 21 22 23 24

tri[r][c] is two lookups: first fetch row pointer tri[r], then index into that row. A true int m[3][5] is one block using the row-major formula instead. A jagged layout like this is impossible with a true 2D array, and it’s exactly how char *argv[] stores command-line arguments of different lengths. Free each row before the table that holds their addresses. The other order would lose the row pointers. (Allocation failures of the individual rows are unchecked here to keep the example short. The memory lesson covers doing it properly.)

const and Pointers

const can protect the data, the pointer, or both. The compiler enforces each one:

int main(void) {
    int x = 10, y = 20;

    const int *p = &x;        /* pointer to const int */
    p = &y;                   /* OK: p may point elsewhere */
    *p = 5;                   /* error: data is read-only through p */

    int *const q = &x;        /* const pointer to int */
    *q = 5;                   /* OK: the data can change */
    q = &y;                   /* error: q itself can't change */

    const int *const r = &x;  /* both const */
    return *p + *q + *r;
}
$ gcc -Wall -Wextra -std=c17 -o constptr constptr.c
constptr.c: In function 'main':
constptr.c:6:8: error: assignment of read-only location '*p'
    6 |     *p = 5;                   /* error: data is read-only through p */
      |        ^
constptr.c:10:7: error: assignment of read-only variable 'q'
   10 |     q = &y;                   /* error: q itself can't change */
      |       ^

Exactly the two marked lines failed. The messages even name what is read-only: the location *p in one case, the variable q in the other. Read declarations right to left: const int *p is “p is a pointer to const int”, and int *const q is “q is a const pointer to int”. const int *p doesn’t make x itself constant. It only blocks writes made through p. That’s why function parameters like const char *s promise the caller that the function won’t modify their data.

void Pointers

A void * can hold any object’s address. That’s how malloc, memcpy and qsort work with every type. To use the data you must convert back to a real type, and a byte-by-byte walk with unsigned char * works for anything:

#include <stdio.h>

/* Generic swap: works on any type, byte by byte */
void swap(void *a, void *b, size_t size) {
    unsigned char *pa = a, *pb = b;
    while (size--) {
        unsigned char tmp = *pa;
        *pa++ = *pb;
        *pb++ = tmp;
    }
}

int main(void) {
    int x = 10, y = 20;
    swap(&x, &y, sizeof x);
    printf("x=%d, y=%d\n", x, y);

    double a = 1.5, b = 2.5;
    swap(&a, &b, sizeof a);
    printf("a=%.1f, b=%.1f\n", a, b);

    char s1[] = "cat", s2[] = "dog";
    swap(s1, s2, sizeof s1);
    printf("s1=%s, s2=%s\n", s1, s2);
    return 0;
}
$ gcc -Wall -Wextra -std=c17 -o voidptr voidptr.c
$ ./voidptr
x=20, y=10
a=2.5, b=1.5
s1=dog, s2=cat

One function swapped an int, a double and a 4-byte char array, because it only moves bytes and the caller supplies the size. In C, a void * converts to any object pointer implicitly, so unsigned char *pa = a needs no cast (C++ would require one). What you can’t do is dereference a void * directly:

$ gcc -Wall -Wextra -std=c17 -o voidderef voidderef.c
voidderef.c: In function 'main':
voidderef.c:7:20: warning: dereferencing 'void *' pointer
    7 |     printf("%d\n", *v);          /* error: void has no size or type */
      |                    ^~
voidderef.c:7:20: error: invalid use of void expression

*v would have type void, which has no size and no value to print. *(int *)v works because the cast tells the compiler what’s at that address. If the cast names the wrong type, the compiler can’t catch it. The type information exists only in your head.

Function Pointers

A function’s name, used without (), is its address. Storing it in a pointer lets you choose which function to call at runtime, as with the comparator qsort calls back into:

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

typedef int (*compare_fn)(const void *, const void *);

int compare_asc(const void *a, const void *b) {
    int x = *(const int *)a, y = *(const int *)b;
    return (x > y) - (x < y);
}

int compare_desc(const void *a, const void *b) {
    return compare_asc(b, a);
}

void sort_and_print(int *arr, size_t n, compare_fn cmp) {
    qsort(arr, n, sizeof arr[0], cmp);    /* qsort calls cmp for us */
    for (size_t i = 0; i < n; i++) printf("%d ", arr[i]);
    printf("\n");
}

int main(void) {
    int data[] = {5, 3, 8, 1, 9, 2};
    size_t n = sizeof data / sizeof data[0];
    sort_and_print(data, n, compare_asc);
    sort_and_print(data, n, compare_desc);

    compare_fn chosen = compare_desc;
    printf("chosen == compare_desc: %s\n", chosen == compare_desc ? "yes" : "no");
    return 0;
}
$ gcc -Wall -Wextra -std=c17 -o funcptr funcptr.c
$ ./funcptr
1 2 3 5 8 9 
9 8 5 3 2 1 
chosen == compare_desc: yes

The raw type int (*)(const void *, const void *) is hard to read, so the typedef names it compare_fn. The parentheses around *compare_fn matter: without them, it would declare a function returning int *. The comparator uses (x > y) - (x < y) rather than x - y, which can overflow (the arrays lesson shows it mis-sorting).

A Dispatch Table

Pairing data with function pointers replaces a long switch with a lookup:

#include <stdio.h>

typedef double (*math_op)(double, double);

double add(double a, double b)    { return a + b; }
double sub(double a, double b)    { return a - b; }
double mul(double a, double b)    { return a * b; }
double divide(double a, double b) { return b != 0 ? a / b : 0; }

struct op { char symbol; math_op fn; };

int main(void) {
    struct op table[] = { {'+', add}, {'-', sub}, {'*', mul}, {'/', divide} };
    const char *program = "+*/-?";

    for (const char *c = program; *c; c++) {
        math_op fn = NULL;
        for (size_t i = 0; i < sizeof table / sizeof table[0]; i++) {
            if (table[i].symbol == *c) fn = table[i].fn;
        }
        if (fn) printf("10 %c 4 = %.2f\n", *c, fn(10, 4));
        else    printf("no operation for '%c'\n", *c);
    }
    return 0;
}
$ gcc -Wall -Wextra -std=c17 -o dispatch dispatch.c
$ ./dispatch
10 + 4 = 14.00
10 * 4 = 40.00
10 / 4 = 2.50
10 - 4 = 6.00
no operation for '?'

A function pointer can be NULL, which makes “not found” easy to express and check before calling. Adding a new operator means adding one table row, with no new branch. This is the pattern behind interpreters, command dispatchers, and the “vtable” structs C libraries use to imitate objects.

Practice

  1. Write int sum(const int *begin, const int *end) that adds every element from begin up to, but not including, end.
Solution
#include <stdio.h>

int sum(const int *begin, const int *end) {
    int total = 0;
    for (const int *p = begin; p != end; p++) total += *p;
    return total;
}

int main(void) {
    int data[] = {3, 1, 4, 1, 5, 9, 2, 6};
    size_t n = sizeof data / sizeof data[0];
    printf("all:        %d\n", sum(data, data + n));
    printf("first half: %d\n", sum(data, data + n / 2));
    printf("empty:      %d\n", sum(data, data));
    return 0;
}
$ gcc -Wall -Wextra -std=c17 -o p1 p1.c
$ ./p1
all:        31
first half: 9
empty:      0

A half-open range [begin, end) needs no length and handles empty ranges for free (begin == end). It relies on “one past the end” being a legal pointer to compare against, which C guarantees.

  1. Write int *find(int *arr, size_t n, int target) that returns a pointer to the first match, or NULL. Use the returned pointer to change that element.
Solution
#include <stdio.h>

int *find(int *arr, size_t n, int target) {
    for (size_t i = 0; i < n; i++) {
        if (arr[i] == target) return &arr[i];
    }
    return NULL;
}

int main(void) {
    int data[] = {4, 8, 15, 16, 23, 42};
    int *hit = find(data, 6, 16);
    if (hit) {
        printf("found %d at index %td\n", *hit, hit - data);
        *hit = 0;       /* modify the array through the returned pointer */
    }
    printf("find(99) = %s\n", find(data, 6, 99) ? "found" : "NULL");
    for (int i = 0; i < 6; i++) printf("%d ", data[i]);
    printf("\n");
    return 0;
}
$ gcc -Wall -Wextra -std=c17 -o p2 p2.c
$ ./p2
found 16 at index 3
find(99) = NULL
4 8 15 0 23 42 

Returning a pointer gives the caller three things at once: whether it was found (NULL or not), where (hit - data), and a way to change it (*hit = 0). It’s the same design as strchr.

  1. Write apply(int *arr, size_t n, int (*fn)(int)) and use it to square, then negate, {1, 2, 3, 4}.
Solution
#include <stdio.h>

void apply(int *arr, size_t n, int (*fn)(int)) {
    for (size_t i = 0; i < n; i++) arr[i] = fn(arr[i]);
}

int square(int x) { return x * x; }
int negate(int x) { return -x; }

int main(void) {
    int data[] = {1, 2, 3, 4};
    apply(data, 4, square);
    apply(data, 4, negate);
    for (int i = 0; i < 4; i++) printf("%d ", data[i]);
    printf("\n");
    return 0;
}
$ gcc -Wall -Wextra -std=c17 -o p3 p3.c
$ ./p3
-1 -4 -9 -16 

apply knows nothing about squaring or negating. The behavior is passed in, which makes it C’s version of map. The parameter can be written as int (*fn)(int) or, equivalently, int fn(int): like arrays, function parameters are adjusted to pointers.

  1. Write int grow(int **arr, size_t *cap) that doubles a dynamic array’s capacity (starting at 4) with realloc, updating both of the caller’s variables.
Solution
#include <stdio.h>
#include <stdlib.h>

int grow(int **arr, size_t *cap) {
    size_t new_cap = *cap ? *cap * 2 : 4;
    int *tmp = realloc(*arr, new_cap * sizeof **arr);
    if (!tmp) return -1;          /* *arr is untouched on failure */
    *arr = tmp;
    *cap = new_cap;
    return 0;
}

int main(void) {
    int *arr = NULL;
    size_t cap = 0;
    for (int i = 0; i < 3; i++) {
        if (grow(&arr, &cap) != 0) return 1;
        printf("capacity is now %zu\n", cap);
    }
    free(arr);
    return 0;
}
$ gcc -Wall -Wextra -std=c17 -o p4 p4.c
$ ./p4
capacity is now 4
capacity is now 8
capacity is now 16

realloc may move the block, so the caller’s pointer must be updated, which takes int **. The result goes into tmp first. Writing *arr = realloc(*arr, ...) directly would overwrite the only pointer to the old block with NULL on failure and leak it. realloc(NULL, n) behaves like malloc(n), which is why starting from NULL works.

Frequently Asked Questions

What is a pointer?
A pointer is a variable that stores a memory address. Instead of holding a value like 42, it holds the location where a value lives. You get an address with the & operator and follow it to the value with the * operator.
What is the difference between *p and &p?
& is the address-of operator: &x gives the address of x, so &p is the address of the pointer variable itself. * is the dereference operator: *p gives the value stored at the address p holds. In a declaration, int *p means p is a pointer to int.
What is a null pointer and when should I use it?
A null pointer, written NULL (or nullptr in C23), is a pointer value guaranteed not to point at any object. Use it to mean 'no valid address', for example as the return value of a search that found nothing or of malloc when allocation fails. Dereferencing it is undefined behavior; on Linux it crashes with a segmentation fault.
How do I read a declaration like const int *const p?
Read it right to left starting from the name: p is a const pointer to an int that is const. A const to the left of the * protects the data being pointed to; a const to the right of the * fixes the pointer itself so it can't be redirected.