The idea of a pointer, and a first example with & and *, is on memory management, next to the stack and the heap. This page goes further: the rules, the mistakes and the tools that catch them. Pointers and arrays are so closely tied in C that they share a page of their own: arrays and pointer arithmetic.

Addresses and dereferencing

Every object in a running program sits somewhere in memory, and its address is the number of its first byte. The address-of operator & gives that address: &watts is "where watts lives". A variable declared as int *p is a pointer to int: it can hold the address of an int. Dereferencing is following the pointer to the object it points to, with the unary * operator: *p is that int itself, which you can read and also assign to.

#include <stdio.h>

int main(void)
{
    int watts = 1234;
    int *p = &watts;            /* p holds the address of watts */

    printf("watts = %d\n", watts);
    printf("*p    = %d\n", *p);
    *p = 980;                   /* write through the pointer */
    printf("watts = %d\n", watts);
    printf("sizeof p = %zu, sizeof *p = %zu\n", sizeof p, sizeof *p);
    printf("p points to watts: %d\n", p == &watts);
    return 0;
}
watts = 1234
*p    = 1234
watts = 980
sizeof p = 8, sizeof *p = 4
p points to watts: 1
Two variables in memory. The int watts, 4 bytes at the illustrative address 0x1010, holds 980. The pointer p, 8 bytes at address 0x1018, holds the value 0x1010, and an arrow from p leads to watts. 0x1010 980 int watts (4 bytes) 0x1018 0x1010 int *p (8 bytes) *p Addresses are illustrative; real ones change on every run.
A pointer is a variable whose value is the address of another object; *p follows it.

On our test machine, a 64-bit Linux machine (ARM64), every pointer is 8 bytes, whatever it points to. The examples on this site never print the address itself: printf("%p", (void *)p) works, but the operating system places the program at a random address on every run (address space layout randomization, ASLR), so the number changes each time. Comparing pointers with == is stable, and is what programs actually need.

Reading pointer declarations

The * in a declaration belongs to the name, not to the type. int *p, q; declares one pointer p and one plain int q, which surprises almost everyone once:

#include <stdio.h>

int main(void)
{
    int *p, q;                  /* p is a pointer, q is a plain int */

    q = 5;
    p = &q;
    printf("sizeof p = %zu, sizeof q = %zu, *p = %d\n", sizeof p, sizeof q, *p);
    return 0;
}
sizeof p = 8, sizeof q = 4, *p = 5

Declare one pointer per line, or write * on each name. The type of a pointer matters: *p on an int * reads 4 bytes as an int, on a double * it reads 8 bytes as a double. Mixing them up is refused:

#include <stdio.h>

int main(void)
{
    int kwh = 350;
    double *p = &kwh;

    printf("%f\n", *p);
    return 0;
}
example.c:6:17: error: initialization of ‘double *’ from incompatible pointer type ‘int *’ [-Werror=incompatible-pointer-types]

gcc 12 warns about this even without any flags; since GCC 14 it is an error by default. A cast such as (double *)&kwh silences the compiler but not the problem: reading an int through a double * is undefined behavior.

Changing the caller's variables

C passes arguments by value (see functions and header files), so a function cannot change a variable it receives. It can change a variable whose address it receives. Pass by pointer is that technique: the caller passes &x, the function takes an int * parameter and writes through it. The pointer itself is still copied, but the copy points at the caller's variable. This is how a C function returns several results:

#include <stdio.h>

/* Splits a power reading in watts into kW and the remaining watts. */
void split_power(int watts, int *kw, int *rest)
{
    *kw = watts / 1000;
    *rest = watts % 1000;
}

int main(void)
{
    int kw, rest;

    split_power(1234, &kw, &rest);
    printf("%d kW and %d W\n", kw, rest);
    return 0;
}
1 kW and 234 W

A common shape combines both: the return value says whether the function succeeded, and the result goes through a pointer. This parser reads the power line of a smart meter's P1 telegram and stores the value in whole watts, an integer, as the P1 page recommends. It relies on DSMR's fixed format of exactly three decimals:

#include <stdio.h>

/* Reads "1-0:1.7.0(01.234*kW)" into watts.
   Returns 1 on success, 0 if the line does not match. */
int parse_power(const char *line, int *watts)
{
    int kw, frac;

    if (sscanf(line, "1-0:1.7.0(%d.%3d*kW)", &kw, &frac) != 2) {
        return 0;
    }
    *watts = kw * 1000 + frac;
    return 1;
}

int main(void)
{
    const char *lines[] = { "1-0:1.7.0(01.234*kW)", "1-0:2.7.0(00.000*kW)" };

    for (int i = 0; i < 2; i++) {
        int watts;
        if (parse_power(lines[i], &watts)) {
            printf("%s -> %d W\n", lines[i], watts);
        } else {
            printf("%s -> not a power line\n", lines[i]);
        }
    }
    return 0;
}
1-0:1.7.0(01.234*kW) -> 1234 W
1-0:2.7.0(00.000*kW) -> not a power line

sscanf itself works the same way: it needs the addresses of the variables to fill, which is why every argument after the format has an &. Forgetting one passes a number where an address is expected. gcc checks scanf-style formats and refuses it; without the check, the program would write to whatever address that number happens to be:

scan.c:6:13: error: format ‘%d’ expects argument of type ‘int *’, but argument 2 has type ‘int’ [-Werror=format=]
    6 |     scanf("%d", watts);
      |            ~^   ~~~~~
      |             |   |
      |             |   int
      |             int *

Null pointers

A null pointer is a pointer value that is guaranteed not to point at any object. It is written NULL (a macro from <stddef.h>, <stdio.h> and other headers), or nullptr in C23. Functions use it to say "nothing": malloc returns NULL when there is no memory, fopen when a file cannot be opened, and your own lookup functions when nothing was found. In a condition a null pointer counts as false, so if (m) means if (m != NULL).

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

struct meter {
    const char *id;
    int kwh;
};

static struct meter meters[] = {
    { "M-30004", 350 },
    { "M-30007", 1500 },
};

/* Returns the meter with this id, or NULL when there is none. */
struct meter *find_meter(const char *id)
{
    for (size_t i = 0; i < sizeof meters / sizeof meters[0]; i++) {
        if (strcmp(meters[i].id, id) == 0) {
            return &meters[i];
        }
    }
    return NULL;
}

int main(void)
{
    const char *wanted[] = { "M-30004", "M-39999" };

    for (int i = 0; i < 2; i++) {
        struct meter *m = find_meter(wanted[i]);
        if (m == NULL) {
            printf("%s: unknown meter\n", wanted[i]);
            continue;
        }
        printf("%s: %d kWh\n", m->id, m->kwh);
    }
    return 0;
}
M-30004: 350 kWh
M-39999: unknown meter

m->kwh is short for (*m).kwh: the member of the struct that m points to (see structs, unions and enums). Leave out the check and the second lookup dereferences a null pointer. That is undefined behavior; on Linux the lowest addresses are not mapped, so the processor raises a fault and the kernel ends the program with the signal SIGSEGV. The shell reports it as a segmentation fault, and the line printed before the crash is the only clue in the output:

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

struct meter {
    const char *id;
    int kwh;
};

static struct meter meters[] = {
    { "M-30004", 350 },
    { "M-30007", 1500 },
};

struct meter *find_meter(const char *id)
{
    for (size_t i = 0; i < sizeof meters / sizeof meters[0]; i++) {
        if (strcmp(meters[i].id, id) == 0) {
            return &meters[i];
        }
    }
    return NULL;
}

int main(void)
{
    const char *wanted[] = { "M-30004", "M-39999" };

    for (int i = 0; i < 2; i++) {
        struct meter *m = find_meter(wanted[i]);
        printf("%s: %d kWh\n", wanted[i], m->kwh);    /* no NULL check */
    }
    return 0;
}
M-30004: 350 kWh
Segmentation fault

A segmentation fault is what service desks and admins most often see of a C bug: a daemon that disappears, exit status 139 in a script, a core dump in the logs. How to find the line responsible with a debugger is shown on debugging C. NULL pointer dereference is number 13 in the CWE Top 25 of 2025, MITRE's list of the most dangerous software weaknesses (see undefined behavior and safer C).

Dangling pointers

A dangling pointer holds the address of an object whose lifetime has ended. The address looks perfectly normal, which is what makes it dangerous: reading through it may return the old value, a new value that has moved in, or crash. There are three ways to get one: returning the address of a local variable, keeping the address of a variable after its block ends, and using memory after free (see dynamic memory).

The first is easy for the compiler to see, and gcc refuses it:

#include <stdio.h>

int *latest_reading(void)
{
    int kwh = 350;
    return &kwh;
}

int main(void)
{
    printf("%d\n", *latest_reading());
    return 0;
}
example.c:6:12: error: function returns address of local variable [-Werror=return-local-addr]

The second is not caught at compile time. Here peak keeps the address of a variable that exists only during one round of the loop. Built normally on our test machine, the program printed 200, the second day's value, because the loop reused the same memory, and exited as if nothing was wrong. AddressSanitizer stops it at the bad read:

#include <stdio.h>

int main(void)
{
    int *peak = NULL;

    for (int day = 1; day <= 2; day++) {
        int kwh = day * 100;
        if (peak == NULL) {
            peak = &kwh;
        }
    }
    printf("peak: %d kWh\n", *peak);     /* kwh no longer exists */
    return 0;
}
SUMMARY: AddressSanitizer: stack-use-after-scope example.c:13 in main

The rule: a pointer must never outlive what it points to. Return values rather than addresses of locals, let the caller pass in the memory to fill (as split_power above does), or allocate on the heap and document who frees it. Setting a pointer to NULL once its target is gone turns a silent dangling pointer into a loud null pointer, but only for that one copy of the address.

const and pointers

const can protect two different things: the object a pointer points to, or the pointer itself. Read the declaration from the name outward, right to left:

Declaration Read as *p = 1 p = &other
int *p pointer to int allowed allowed
const int *p (same as int const *p) pointer to const int error allowed
int *const p const pointer to int allowed error
const int *const p const pointer to const int error error
#include <stdio.h>

int main(void)
{
    int kwh = 350;
    int limit = 1000;

    const int *reader = &kwh;   /* may move, may not write */
    int *const fixed = &kwh;    /* may write, may not move */

    *fixed = 400;               /* fine: changes kwh */
    reader = &limit;            /* fine: now points at limit */
    printf("kwh %d, *reader %d\n", kwh, *reader);
    return 0;
}
kwh 400, *reader 1000

Writing through a pointer to const does not compile:

#include <stdio.h>

int main(void)
{
    int kwh = 350;
    const int *p = &kwh;

    *p = 400;
    printf("%d\n", kwh);
    return 0;
}
example.c:8:8: error: assignment of read-only location ‘*p’

The most useful form is const on parameters, as in parse_power(const char *line, int *watts) above: the prototype promises that line is only read, while watts will be written. The compiler holds the function to that promise, and the reader of the prototype knows which arguments change. Note that const int * only means "not changed through this pointer"; the object itself may still change in other ways.

void pointers

A void pointer, void *, holds an address without saying what type of object is there. Any object pointer converts to void * and back without a cast, which makes it C's way of writing generic code: malloc returns a void *, and memcpy, memset and qsort take them, because they work on any kind of data. This function prints the bytes of any object:

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

/* Prints any block of memory as hexadecimal bytes. */
static void dump(const char *label, const void *data, size_t size)
{
    const unsigned char *bytes = data;     /* void * converts without a cast */

    printf("%-8s", label);
    for (size_t i = 0; i < size; i++) {
        printf(" %02X", bytes[i]);
    }
    printf("\n");
}

int main(void)
{
    int watts = 1234;
    unsigned short crc = 0xF1F4;
    char unit[] = "kW";

    dump("watts", &watts, sizeof watts);
    dump("crc", &crc, sizeof crc);
    dump("unit", unit, sizeof unit);
    return 0;
}
watts    D2 04 00 00
crc      F4 F1
unit     6B 57 00

The output shows two things that are easy to forget. 1234 is 0x04D2, but its lowest byte comes first in memory, because this machine is little-endian (see the standard library and portability). And the string "kW" takes three bytes: the last one is the terminating zero (see strings and buffer overflows).

Because the compiler does not know what a void * points to, it cannot dereference one or do arithmetic with it. Convert it to a real pointer type first:

#include <stdio.h>

int main(void)
{
    int watts = 1234;
    void *p = &watts;

    printf("%d\n", *p);
    return 0;
}
example.c:8:20: error: dereferencing ‘void *’ pointer [-Werror]

Pointers to pointers

A pointer is a variable too, so it has an address. A pointer to pointer, such as int **pp, holds the address of a pointer; *pp is that pointer and **pp the object it points to. You meet them for two reasons. The first is a function that must change the caller's pointer, not just the object behind it: pass by pointer, one level up. This parser moves the caller's position in a P1 line forward after each number it reads:

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

/* Reads a number at *cursor and moves the caller's cursor past it. */
static long next_number(const char **cursor)
{
    char *end;
    long value = strtol(*cursor, &end, 10);

    *cursor = end;              /* changes the caller's pointer */
    while (**cursor != '\0' && !isdigit((unsigned char)**cursor)) {
        (*cursor)++;            /* skip separators */
    }
    return value;
}

int main(void)
{
    const char *line = "0-0:96.7.21(00002)";
    const char *pos = line;

    printf("numbers:");
    while (*pos != '\0') {
        printf(" %ld", next_number(&pos));
    }
    printf("\n");
    return 0;
}
numbers: 0 0 96 7 21 2

The parentheses in (*cursor)++ matter: *cursor++ would move the local cursor instead of the caller's pointer. The second reason is an array of pointers, such as an array of strings. Passed to a function, it becomes a pointer to its first element, which is a pointer: char **. That is the type of argv in int main(int argc, char **argv).

Function pointers

A function also has an address, and a function pointer stores it so that the code to run can be chosen at run time. The syntax is the hardest part: int (*order)(const void *, const void *) declares order as a pointer to a function taking two const void * and returning int. The parentheses around *order are required; without them it declares a function returning a pointer.

The standard library's qsort sorts any array, because you pass it the comparison as a function pointer. Its comparison function receives two const void * and returns a negative number, zero or a positive number:

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

static int ascending(const void *a, const void *b)
{
    int x = *(const int *)a;
    int y = *(const int *)b;
    return (x > y) - (x < y);     /* -1, 0 or 1, and never overflows */
}

static int descending(const void *a, const void *b)
{
    return ascending(b, a);
}

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

int main(void)
{
    int kwh[] = {130, 95, 1500, 120};
    size_t n = sizeof kwh / sizeof kwh[0];
    int (*order)(const void *, const void *) = ascending;

    qsort(kwh, n, sizeof kwh[0], order);
    print_all("ascending:", kwh, n);

    order = descending;
    qsort(kwh, n, sizeof kwh[0], order);
    print_all("descending:", kwh, n);
    return 0;
}
ascending:  95 120 130 1500
descending: 1500 130 120 95

A typedef gives a function pointer type a readable name. Firmware and network code often keep a table that maps an input to the function that handles it, a dispatch table; here OBIS codes from a P1 telegram are routed to handlers:

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

typedef void (*handler)(const char *value);

static void on_power(const char *value)   { printf("power now: %s\n", value); }
static void on_voltage(const char *value) { printf("voltage:   %s\n", value); }

struct route {
    const char *obis;
    handler handle;
};

static const struct route routes[] = {
    { "1-0:1.7.0", on_power },
    { "1-0:32.7.0", on_voltage },
};

int main(void)
{
    const char *obis[] = { "1-0:32.7.0", "1-0:1.7.0", "0-0:96.14.0" };
    const char *values[] = { "231.4*V", "01.234*kW", "0002" };

    for (int i = 0; i < 3; i++) {
        int handled = 0;
        for (size_t r = 0; r < sizeof routes / sizeof routes[0]; r++) {
            if (strcmp(routes[r].obis, obis[i]) == 0) {
                routes[r].handle(values[i]);
                handled = 1;
            }
        }
        if (!handled) {
            printf("ignored:   %s\n", obis[i]);
        }
    }
    return 0;
}
voltage:   231.4*V
power now: 01.234*kW
ignored:   0-0:96.14.0

Callbacks work the same way: a framework stores your function's address and calls it later, for example when a pin changes (see GPIO, PWM and interrupts). A function pointer that is null, or that was overwritten by a memory bug, sends the program to a wrong address; that is one reason why memory-safety bugs in C can be so much worse than a wrong number.