Pointer Arithmetic in C Explained — How, Why, and When to Use It
Most C bugs that end careers — buffer overflows, segfaults, corrupted data — trace back to one misunderstood concept: pointer arithmetic. It's not obscure wizardry reserved for OS kernels and embedded systems. It's the engine under every array you've ever used in C, every string you've ever printed, every memcpy that ever moved bytes across memory. If you've ever wondered why arrays and pointers feel interchangeable in C, pointer arithmetic is the answer.
The problem pointer arithmetic solves is elegant: how do you navigate raw memory without knowing the exact address of every element ahead of time? Arrays give you contiguous memory, but they're just a starting address. Pointer arithmetic lets you move through that memory systematically, letting the compiler handle the messy byte-offset calculations based on the data type's size. Without it, you'd be manually computing byte offsets for every element access — error-prone and unreadable.
By the end of this article you'll understand exactly why ptr + 1 doesn't mean 'add 1 byte', how to traverse arrays and strings without index variables, what makes pointer subtraction meaningful, and which patterns experienced C developers actually reach for in production code. You'll also know the two arithmetic operations that look valid but silently corrupt memory — and how to spot them before they bite you.
Why the Compiler Scales Pointer Steps by the Type Size
Here's the core insight that unlocks everything: when you write ptr + 1, C doesn't add the integer 1 to the address. It adds 1 sizeof(ptr). That scaling is automatic, silent, and non-negotiable.
Why does this exist? Because memory is byte-addressable, but your data isn't byte-sized. An int on most modern systems occupies 4 bytes. If you have an array of ints starting at address 1000, the second element is at address 1004, the third at 1008. Writing ptr + 2 to mean 'skip 8 bytes' would force every programmer to manually multiply by sizeof(int) everywhere — a nightmare that would produce subtly broken code whenever you changed the data type.
C's designers baked the scaling in so that ptr + n always means 'the address of the nth element after ptr', regardless of whether ptr points to a char, an int, a double, or a 200-byte struct. This is also why ptr++ on a double* moves 8 bytes forward, not 1.
This design decision is what makes array[i] syntactic sugar. The compiler literally rewrites it as *(array + i) — pointer arithmetic followed by a dereference. They are exactly the same operation.
#include <stdio.h> int main(void) { int scores[5] = {10, 20, 30, 40, 50}; char letters[5] = {'A', 'B', 'C', 'D', 'E'}; double temps[5] = {36.6, 37.1, 38.0, 36.9, 37.5}; int *score_ptr = scores; /* points to scores[0] */ char *letter_ptr = letters; /* points to letters[0] */ double *temp_ptr = temps; /* points to temps[0] */ /* Adding 1 to each pointer — watch how far each jumps */ printf("=== Type sizes on this machine ===\n"); printf("sizeof(int) = %zu bytes\n", sizeof(int)); printf("sizeof(char) = %zu bytes\n", sizeof(char)); printf("sizeof(double) = %zu bytes\n", sizeof(double)); printf("\n=== Address jump when pointer is incremented by 1 ===\n"); printf("score_ptr before: %p\n", (void *)score_ptr); printf("score_ptr after: %p (jumped %zu bytes)\n", (void *)(score_ptr + 1), sizeof(int)); printf("letter_ptr before: %p\n", (void *)letter_ptr); printf("letter_ptr after: %p (jumped %zu bytes)\n", (void *)(letter_ptr + 1), sizeof(char)); printf("temp_ptr before: %p\n", (void *)temp_ptr); printf("temp_ptr after: %p (jumped %zu bytes)\n", (void *)(temp_ptr + 1), sizeof(double)); /* Prove array[i] == *(array + i) */ printf("\n=== array[2] vs *(array + 2) ===\n"); printf("scores[2] = %d\n", scores[2]); /* subscript form */ printf("*(scores + 2) = %d\n", *(scores + 2)); /* pointer form */ printf("*(score_ptr + 2) = %d\n", *(score_ptr + 2)); /* via pointer var */ return 0; }
sizeof(int) = 4 bytes
sizeof(char) = 1 bytes
sizeof(double) = 8 bytes
=== Address jump when pointer is incremented by 1 ===
score_ptr before: 0x7ffd2a3b1010
score_ptr after: 0x7ffd2a3b1014 (jumped 4 bytes)
letter_ptr before: 0x7ffd2a3b1024
letter_ptr after: 0x7ffd2a3b1025 (jumped 1 bytes)
temp_ptr before: 0x7ffd2a3b1030
temp_ptr after: 0x7ffd2a3b1038 (jumped 8 bytes)
=== array[2] vs *(array + 2) ===
scores[2] = 30
*(scores + 2) = 30
*(score_ptr + 2) = 30
Traversing Arrays and Strings the Way the Standard Library Does It
Now that scaling makes sense, let's look at why experienced C programmers sometimes prefer pointer traversal over index-based loops — and when it actually matters.
The standard library functions like strlen, strcpy, and memcpy are all implemented internally using pointer arithmetic. There's no index counter ticking up — there's a pointer walking forward until a condition is met. This pattern is worth knowing not because it's faster on modern CPUs (it often isn't; compilers are clever), but because it teaches you to think in terms of memory, not arrays.
Pointer traversal also shines when you're working with a section of an array — a slice. Instead of passing both an array and an index, you pass a pointer to where you want to start. The function doesn't need to know the original array at all. This is exactly how C string functions handle substrings: strstr returns a pointer into the original string, not a copy.
The key habit: always keep a sentinel — either a count of elements or a terminator value like '\0' — so your pointer knows when to stop.
#include <stdio.h> /* Count characters in a string — reimplementing strlen with visible pointer steps */ size_t count_chars(const char *text) { const char *cursor = text; /* cursor walks forward; text stays at the start */ while (*cursor != '\0') { /* dereference: read the byte cursor is pointing at */ cursor++; /* move cursor one char (1 byte) forward */ } /* Subtracting two pointers gives element count between them, not byte count */ return (size_t)(cursor - text); } /* Sum a slice of an integer array — no index needed, just start + end pointers */ int sum_range(const int *start, const int *end) { int total = 0; /* 'end' is one-past-the-last valid element — a standard C idiom */ for (const int *ptr = start; ptr < end; ptr++) { total += *ptr; /* dereference ptr to read the current element */ } return total; } /* Find the first negative number in an array; return pointer to it or NULL */ int *find_first_negative(int *data, size_t count) { int *ptr = data; int *sentinel = data + count; /* one past the end — do NOT dereference this */ while (ptr < sentinel) { if (*ptr < 0) return ptr; /* return the actual address inside the array */ ptr++; } return NULL; /* no negative found */ } int main(void) { /* --- String traversal --- */ const char *message = "TheCodeForge"; printf("String: \"%s\"\n", message); printf("Length via count_chars: %zu\n", count_chars(message)); /* --- Slice summing --- */ int readings[8] = {12, 7, 34, 5, 89, 23, 61, 4}; /* Sum only elements at index 2, 3, 4 (readings[2] through readings[4]) */ int slice_sum = sum_range(readings + 2, readings + 5); printf("\nReadings array: {12, 7, 34, 5, 89, 23, 61, 4}\n"); printf("Sum of slice [2..4]: %d (expected: 34+5+89 = 128)\n", slice_sum); /* --- Finding negative values --- */ int temperatures[6] = {22, 18, -3, 25, -1, 30}; int *first_negative = find_first_negative(temperatures, 6); if (first_negative != NULL) { /* Pointer subtraction tells us which index it landed on */ ptrdiff_t index = first_negative - temperatures; printf("\nFirst negative: %d at index %td\n", *first_negative, index); } return 0; }
Length via count_chars: 12
Readings array: {12, 7, 34, 5, 89, 23, 61, 4}
Sum of slice [2..4]: 128 (expected: 34+5+89 = 128)
First negative: -3 at index 2
Legal vs. Illegal Pointer Arithmetic — The Rules That Prevent Chaos
Not all pointer arithmetic is created equal. C's standard is precise about what's defined behaviour and what silently explodes.
Legal operations: You can add or subtract an integer to/from a pointer. You can subtract two pointers that point into the same array (including the one-past-the-end position). You can compare two pointers from the same array with <, >, <=, >=. That's it.
Illegal operations: Adding two pointers together is a compile error — it has no geometric meaning. Subtracting pointers from different arrays is undefined behaviour — the result might 'work' on your machine today and crash on the build server tomorrow. Dereferencing the one-past-the-end pointer is undefined behaviour, even though computing its address is fine.
The 'one past the end' rule is subtle but critical. C explicitly allows you to form the address array + N (where N is the array length) as a sentinel for loops. This is valid address arithmetic. What's undefined is actually reading or writing through that address — *(array + N) — because that memory isn't yours.
Pointer comparison with == and != is safe between any two pointers, even from different objects, but < and > between pointers from different objects is undefined. This matters when you're implementing a memory allocator or anything that reasons about relative positions.
#include <stdio.h> #include <stddef.h> int main(void) { int buffer[5] = {100, 200, 300, 400, 500}; int *start = buffer; /* points to buffer[0] */ int *end = buffer + 5; /* one-past-end: valid ADDRESS, never dereference */ /* === LEGAL: integer + pointer === */ int *third_element = start + 2; /* buffer[2], address is buffer_base + 8 */ printf("Legal: start + 2 = %d\n", *third_element); /* === LEGAL: pointer - pointer (same array) === */ ptrdiff_t distance = end - start; /* gives 5 — element count, not byte count */ printf("Legal: end - start = %td elements\n", distance); /* === LEGAL: pointer comparison within same array === */ int *cursor = start; int count = 0; while (cursor < end) { /* cursor < end is legal — both point into buffer */ count++; cursor++; } printf("Legal: counted %d elements via pointer comparison\n", count); /* === LEGAL: one-past-end address computation (no dereference) === */ printf("Legal: end address computed = %p (not dereferenced)\n", (void *)end); /* === ILLEGAL (commented out to keep this code safe to run) === // 1. Adding two pointers — compile error: // int *bad = start + end; // error: invalid operands to binary + // 2. Subtracting pointers from DIFFERENT arrays — undefined behaviour: // int other[5] = {1,2,3,4,5}; // ptrdiff_t garbage = buffer - other; // UB: might crash, might return wrong value // 3. Dereferencing one-past-end — undefined behaviour: // int forbidden = *end; // UB: reading memory that isn't yours // 4. Pointer arithmetic past the bounds — undefined behaviour: // int *too_far = start + 10; // UB even before dereferencing on some platforms */ printf("\nAll legal operations completed safely.\n"); return 0; }
Legal: end - start = 5 elements
Legal: counted 5 elements via pointer comparison
Legal: end address computed = 0x7ffd1a2c3024 (not dereferenced)
All legal operations completed safely.
Real-World Pattern: Using Pointer Arithmetic in a Custom Memory Buffer
Let's put it all together with a pattern you'll actually encounter: a simple write buffer that tracks its own current position using pointer arithmetic. This is the core idea behind arena allocators, packet serializers, and file format writers.
The idea is straightforward: you allocate a fixed block of memory, keep a write_ptr that starts at the beginning, and advance it each time you write data. Pointer arithmetic tells you how much space you've used (write_ptr - buffer_start) and how much you have left (buffer_end - write_ptr).
This pattern is used in real systems because it avoids repeated malloc calls and gives you cache-friendly contiguous storage. Game engines, network stacks, and embedded firmware all use variations of this. Understanding it requires being comfortable with pointer arithmetic: you need to write to the current position, advance past what you just wrote, and check bounds without ever losing track of where you started.
The code below shows a minimal version you can actually run — a byte buffer that serializes integers and strings one after another, with boundary checking at each step.
#include <stdio.h> #include <string.h> /* memcpy */ #include <stdint.h> /* uint8_t */ #include <stddef.h> /* ptrdiff_t, size_t */ #define BUFFER_CAPACITY 64 typedef struct { uint8_t storage[BUFFER_CAPACITY]; /* raw byte storage */ uint8_t *write_ptr; /* where the next byte will be written */ uint8_t *end_ptr; /* one-past-end sentinel for bounds checks */ } WriteBuffer; void buffer_init(WriteBuffer *buf) { buf->write_ptr = buf->storage; /* start at the beginning */ buf->end_ptr = buf->storage + BUFFER_CAPACITY;/* one past the last byte */ } /* Returns bytes used so far */ size_t buffer_used(const WriteBuffer *buf) { return (size_t)(buf->write_ptr - buf->storage); /* pointer subtraction = count */ } /* Returns bytes remaining */ size_t buffer_remaining(const WriteBuffer *buf) { return (size_t)(buf->end_ptr - buf->write_ptr); /* space left to write into */ } /* Write a 32-bit integer into the buffer (big-endian) */ int buffer_write_int32(WriteBuffer *buf, int32_t value) { if (buffer_remaining(buf) < sizeof(int32_t)) { return -1; /* not enough space */ } /* memcpy is the safe way to write non-char data through a byte pointer */ memcpy(buf->write_ptr, &value, sizeof(int32_t)); buf->write_ptr += sizeof(int32_t); /* advance past the 4 bytes we just wrote */ return 0; } /* Write a null-terminated string (without the null terminator) into the buffer */ int buffer_write_string(WriteBuffer *buf, const char *text) { size_t text_length = strlen(text); if (buffer_remaining(buf) < text_length) { return -1; /* not enough space */ } memcpy(buf->write_ptr, text, text_length); buf->write_ptr += text_length; /* advance past the string bytes we wrote */ return 0; } int main(void) { WriteBuffer packet; buffer_init(&packet); printf("Buffer capacity: %d bytes\n", BUFFER_CAPACITY); printf("Initial — used: %zu, remaining: %zu\n", buffer_used(&packet), buffer_remaining(&packet)); /* Write a 32-bit sensor ID */ int32_t sensor_id = 4029; buffer_write_int32(&packet, sensor_id); printf("After writing int32 (%d) — used: %zu, remaining: %zu\n", sensor_id, buffer_used(&packet), buffer_remaining(&packet)); /* Write a status string */ const char *status = "SENSOR_OK"; buffer_write_string(&packet, status); printf("After writing string (\"%s\") — used: %zu, remaining: %zu\n", status, buffer_used(&packet), buffer_remaining(&packet)); /* Write another int */ int32_t timestamp = 1718000000; buffer_write_int32(&packet, timestamp); printf("After writing timestamp (%d) — used: %zu, remaining: %zu\n", timestamp, buffer_used(&packet), buffer_remaining(&packet)); /* Show the raw bytes written */ printf("\nRaw bytes in buffer (%zu total):\n", buffer_used(&packet)); for (uint8_t *p = packet.storage; p < packet.write_ptr; p++) { /* Print hex for non-printable bytes, char for printable ones */ if (*p >= 32 && *p < 127) printf(" '%c'", *p); else printf(" %02X", *p); } printf("\n"); return 0; }
Initial — used: 0, remaining: 64
After writing int32 (4029) — used: 4, remaining: 60
After writing string ("SENSOR_OK") — used: 13, remaining: 51
After writing timestamp (1718000000) — used: 17, remaining: 47
Raw bytes in buffer (17 total):
BD 0F 00 00 'S' 'E' 'N' 'S' 'O' 'R' '_' 'O' 'K' 00 49 47 66
| Aspect | Index-Based Access (array[i]) | Pointer Arithmetic (*ptr) |
|---|---|---|
| Readability | High — intent is obvious to all readers | Moderate — requires knowing the pattern |
| What compiler generates | Identical machine code after optimisation | Identical machine code after optimisation |
| Bounds checking | No automatic checking in C either way | No automatic checking in C either way |
| Slice / subrange passing | Must pass both array + start index | Pass a single pointer to the start element |
| String walking (no length) | Awkward — need a manual index counter | Natural — cursor++ until sentinel hit |
| Pointer subtraction (distance) | Not directly applicable | ptr_b - ptr_a gives element count between them |
| Risk of going out of bounds | Easy to catch via off-by-one in loop condition | Slightly harder — no obvious upper bound without sentinel |
| Used in standard library | Rarely in implementation | Universally — strlen, memcpy, strchr all use it |
🎯 Key Takeaways
ptr + nalways means 'skip n elements', not 'skip n bytes' — the compiler multiplies n by sizeof(*ptr) automatically, which is why changing a pointer's type changes how far it moves.array[i]and*(array + i)are 100% identical after compilation — the subscript operator is defined as pointer arithmetic plus a dereference, which is why3[array]is valid (if terrible) C.- Pointer subtraction gives you the element count between two pointers, not the byte count — store it in
ptrdiff_t, neverint, to survive on 64-bit platforms with large arrays. - The one-past-end address is legal to compute and compare against but undefined to dereference — this is the foundation of every
for (ptr = start; ptr < end; ptr++)loop in the C standard library.
⚠ Common Mistakes to Avoid
- ✕Mistake 1: Treating pointer arithmetic as byte arithmetic — Symptom: reading corrupted or wrong values when stepping through an array, e.g. getting garbage after writing
ptr = (int)((char)ptr + 1)to advance by 'one' — Fix: let C's type system do the scaling. Writeptr + 1orptr++on a correctly typed pointer and the compiler multiplies by sizeof automatically. Only use explicit byte offsets when you genuinely need to work at the byte level, and usecharoruint8_tfor that. - ✕Mistake 2: Dereferencing the one-past-the-end pointer — Symptom: intermittent crashes or silent data corruption, often reading a valid-looking value that belongs to the next variable on the stack — Fix: your loop sentinel should always be
ptr < end_ptr, neverptr <= end_ptr. The one-past-end address is legal to compute and compare against, but the instant you write*end_ptryou have undefined behaviour regardless of what happens to be sitting at that address. - ✕Mistake 3: Storing pointer subtraction results in an
int— Symptom: correct results on small arrays in testing, then mysterious negative numbers or truncated counts on large arrays in production — Fix: always useptrdiff_t(from) to store the result of subtracting two pointers. On 64-bit platforms, arrays can be large enough that the difference overflows a 32-bit int.ptrdiff_tis guaranteed by the standard to hold any valid pointer difference for the target platform.
Interview Questions on This Topic
- QIf `int *ptr` points to the first element of an `int` array, what is the numeric difference between `ptr+3` and `ptr` on a system where sizeof(int) is 4? And what does that tell you about how pointer arithmetic scales?
- QWhat is the difference between `ptr++` and `++ptr` when used in an expression like `*ptr++`? Walk me through exactly what gets read and what gets incremented, and in what order.
- QWhy is subtracting two pointers that point into different arrays undefined behaviour in C, even if both pointers happen to be valid addresses? What could go wrong at the hardware or compiler optimisation level?
Frequently Asked Questions
Why does adding 1 to a pointer not move it by 1 byte in C?
Because C scales all pointer arithmetic by the size of the pointed-to type. If your pointer is an int* and sizeof(int) is 4, then ptr + 1 advances the address by 4 bytes so it points to the next integer. This design means you always think in elements, not bytes, which prevents an entire class of offset calculation bugs.
Is pointer arithmetic faster than array indexing in C?
No — on any modern optimising compiler they produce identical machine code. array[i] is literally defined as *(array + i), so the compiler sees the same thing either way. Choose whichever form makes the code clearer for the reader. Reserve pointer-walking style for cases where you genuinely don't have an index, like traversing a null-terminated string.
Can you do pointer arithmetic on a void pointer in C?
Not in standard C. Because void has no type, the compiler can't compute sizeof(ptr) and therefore can't scale the arithmetic. GCC accepts void arithmetic as an extension (treating it like char), but this is non-standard and non-portable. Cast to the correct type — or to char/uint8_t if you genuinely want byte-level stepping — before performing arithmetic.
Written and reviewed by senior developers with real-world experience across enterprise, startup and open-source projects. Every article on TheCodeForge is written to be clear, accurate and genuinely useful — not just SEO filler.