C Linked Lists
A linked list is a linear data structure in which each element, called a node, stores a value together with a pointer to the next node in the sequence. Unlike an array, whose elements occupy one contiguous block of memory, the nodes of a linked list can live anywhere on the heap and are connected purely through pointers. This gives linked lists a flexibility arrays don’t have: you can insert or remove an element without shifting every other element, and the list can grow or shrink at runtime without ever being resized. Linked lists are also the foundation for many other structures you will build in C, such as stacks, queues, hash table buckets, and graphs, so learning to build and manipulate one with raw pointers and malloc is a core C skill.
Overview / How It Works
Every node has two parts: a data field that holds the value you care about, and a pointer field, usually named next, that stores the address of the following node. The list itself is represented by a single pointer variable, conventionally called head, which points at the first node. The last node’s next field is set to NULL, which is how code knows it has reached the end of the list. An empty list is simply a head that equals NULL.
Each node is allocated individually on the heap with malloc, not declared as a normal variable or array element. This is the key difference from an array: an array is one allocation of n contiguous slots, so reading element i is a single pointer-arithmetic calculation, an O(1) operation, but inserting in the middle means shifting every element after it. A linked list has no contiguous block at all; each node is a separate allocation scattered wherever the heap allocator happens to place it. Reaching the k-th node means starting at head and following next pointers one at a time, an O(n) walk, but once you are positioned at a node, inserting or removing a neighbor is just a couple of pointer reassignments, O(1), with no shifting required.
| Operation | Array | Singly Linked List |
|---|---|---|
| Access by index | O(1) | O(n) |
| Insert/delete at front | O(n) (shift) | O(1) |
| Insert/delete at end | O(1) amortized (dynamic array) | O(n) without a tail pointer |
| Insert/delete once positioned | O(n) (shift) | O(1) |
| Extra memory per element | none | one pointer (+ padding) |
This trade-off is the whole reason linked lists exist: reach for one when you need frequent insertion and deletion, especially at the front or once you already have a pointer to the location, and prefer arrays when you need fast random access or better cache locality. Because nodes are scattered across the heap, iterating a linked list is also slower in practice than iterating an array of the same size, even though both are described as O(n); this is due to CPU cache misses, since an array’s elements sit next to each other in memory while a list’s nodes do not. Each node also carries the overhead of one extra pointer, 8 bytes on a typical 64-bit system, plus whatever padding the compiler adds for alignment.
The variant described in this lesson is a singly linked list, where each node points only to the next one, so you can only walk forward. A doubly linked list adds a second pointer, prev, so you can walk backward too, at the cost of extra memory and more pointer updates per insert or delete. A circular linked list makes the last node point back to the first instead of to NULL. Once you are comfortable with the singly linked list, those variants are small extensions of the same idea.
Syntax
A linked list node is declared as a struct that contains a pointer to its own type:
struct Node {
int data;
struct Node *next;
};
The parts of this declaration:
struct Nodeis the tag name of the structure. It must appear before*nextbecause the struct refers to itself; this is legal in C specifically becausenextis a pointer, whose size is known at compile time, rather than a nestedstruct Nodevalue, whose size would not be.int datais the payload. It can be any type, or even another struct, depending on what the list needs to store.struct Node *nextis a pointer to the next node, orNULLif this is the last node.
Many programmers add a typedef so they don’t have to write struct Node everywhere:
typedef struct Node {
int data;
struct Node *next;
} Node;
With the typedef, a pointer to a node can be declared as Node *head; instead of struct Node *head;. The examples below use the plain struct Node form for clarity, but both styles are common in real code.
Examples
Example 1: Building and printing a list by hand
This first example allocates three nodes individually and links them together manually, then walks the list to print every value.
#include <stdio.h>
#include <stdlib.h>
struct Node {
int data;
struct Node *next;
};
struct Node *createNode(int value) {
struct Node *newNode = malloc(sizeof(struct Node));
if (newNode == NULL) {
fprintf(stderr, "Memory allocation failed\n");
exit(1);
}
newNode->data = value;
newNode->next = NULL;
return newNode;
}
void printList(struct Node *head) {
struct Node *current = head;
while (current != NULL) {
printf("%d -> ", current->data);
current = current->next;
}
printf("NULL\n");
}
int main(void) {
struct Node *head = createNode(10);
head->next = createNode(20);
head->next->next = createNode(30);
printList(head);
struct Node *current = head;
while (current != NULL) {
struct Node *temp = current;
current = current->next;
free(temp);
}
return 0;
}
Output:
10 -> 20 -> 30 -> NULL
createNode asks the heap allocator for enough memory to hold one struct Node, fills in its fields, and hands back the address. main chains three of these nodes together by pointing each node’s next field at the following one, and printList starts at head and keeps following next until it hits NULL. Notice the final loop, which frees every node before the program exits; every successful malloc must eventually be matched with a free, and it must happen in this order, saving the next pointer before freeing the current node, or you lose access to the rest of the list.
Example 2: Insert-at-head and insert-at-end helper functions
Manually wiring pointers in main does not scale. Real code wraps insertion in reusable functions that take the current head and return the (possibly new) head.
#include <stdio.h>
#include <stdlib.h>
struct Node {
int data;
struct Node *next;
};
struct Node *insertAtHead(struct Node *head, int value) {
struct Node *newNode = malloc(sizeof(struct Node));
newNode->data = value;
newNode->next = head;
return newNode;
}
struct Node *insertAtEnd(struct Node *head, int value) {
struct Node *newNode = malloc(sizeof(struct Node));
newNode->data = value;
newNode->next = NULL;
if (head == NULL) {
return newNode;
}
struct Node *current = head;
while (current->next != NULL) {
current = current->next;
}
current->next = newNode;
return head;
}
void printList(struct Node *head) {
for (struct Node *current = head; current != NULL; current = current->next) {
printf("%d ", current->data);
}
printf("\n");
}
void freeList(struct Node *head) {
struct Node *current = head;
while (current != NULL) {
struct Node *next = current->next;
free(current);
current = next;
}
}
int main(void) {
struct Node *list = NULL;
list = insertAtEnd(list, 2);
list = insertAtEnd(list, 3);
list = insertAtEnd(list, 4);
list = insertAtHead(list, 1);
printf("List: ");
printList(list);
freeList(list);
return 0;
}
Output:
List: 1 2 3 4
insertAtEnd handles two cases: an empty list, where the new node simply becomes the head, and a non-empty list, where it walks to the last node (the one whose next is NULL) and attaches the new node there. insertAtHead is simpler and O(1) because it never has to walk anything; it just points the new node at the old head and returns the new node as the new head. This is why lists that only ever insert at the front are cheap, while repeatedly inserting at the end of a long list without a tail pointer gets slower as the list grows.
Example 3: Searching for and deleting a node by value
Deleting from the middle of a list requires keeping track of the node before the one you want to remove, so you can reconnect the list around the gap.
#include <stdio.h>
#include <stdlib.h>
struct Node {
int data;
struct Node *next;
};
struct Node *insertAtEnd(struct Node *head, int value) {
struct Node *newNode = malloc(sizeof(struct Node));
newNode->data = value;
newNode->next = NULL;
if (head == NULL) {
return newNode;
}
struct Node *current = head;
while (current->next != NULL) {
current = current->next;
}
current->next = newNode;
return head;
}
struct Node *deleteValue(struct Node *head, int value) {
struct Node *current = head;
struct Node *previous = NULL;
while (current != NULL && current->data != value) {
previous = current;
current = current->next;
}
if (current == NULL) {
printf("Value %d not found\n", value);
return head;
}
if (previous == NULL) {
head = current->next;
} else {
previous->next = current->next;
}
free(current);
return head;
}
void printList(struct Node *head) {
for (struct Node *c = head; c != NULL; c = c->next) {
printf("%d ", c->data);
}
printf("\n");
}
int main(void) {
struct Node *list = NULL;
list = insertAtEnd(list, 5);
list = insertAtEnd(list, 10);
list = insertAtEnd(list, 15);
list = insertAtEnd(list, 20);
printf("Before: ");
printList(list);
list = deleteValue(list, 10);
printf("After: ");
printList(list);
struct Node *current = list;
while (current != NULL) {
struct Node *temp = current;
current = current->next;
free(temp);
}
return 0;
}
Output:
Before: 5 10 15 20
After: 5 15 20
deleteValue walks the list keeping two pointers: current, the node being examined, and previous, the node right before it. If the value is found at the head (previous is still NULL), the function moves head forward. Otherwise it splices the node out by making previous->next skip over current, then frees current. Searching for a value that is not present is handled too: the loop stops when current becomes NULL, and the function reports failure instead of crashing.
How It Works Step by Step (Under the Hood)
Walking through Example 1 line by line shows what is actually happening in memory:
malloc(sizeof(struct Node))asks the operating system’s heap allocator for a block of bytes large enough to hold anintand a pointer, and returns the address of that block, orNULLif the request cannot be satisfied.- That address is stored in the local pointer variable
newNode. At this point the memory is uninitialized; the two field assignments that follow (newNode->data = value;andnewNode->next = NULL;) write actual values into it. - When
maindoeshead->next = createNode(20);, it is storing the address of the second node inside thenextfield of the first node. Nothing is copied except that address; the two nodes now simply know about each other through pointers, wherever the allocator actually placed them in memory. printListstarts a local pointer,current, athead, and repeatedly dereferences it withcurrent->datato print the value, then reassignscurrent = current->nextto hop to the next node. The loop conditioncurrent != NULLis what eventually stops the walk, because the last node’snextwas explicitly set toNULLwhen it was created.- The cleanup loop in
mainis careful to copycurrentintotempbefore callingfree(temp), and only advancescurrentusing the value it already saved. If you calledfree(current)and then tried to readcurrent->nextafterward, you would be reading memory the allocator considers free to reuse, which is undefined behavior.
Common Mistakes
1. Losing the head pointer
Overwriting head before the old chain has been reattached orphans every node that was reachable through it; that memory can never be freed again and is leaked for the life of the program.
/* Wrong: the node holding 1 becomes unreachable */
struct Node *head = createNode(1);
head = createNode(2);
/* Correct: link the new node to the old head first */
struct Node *head = createNode(1);
struct Node *second = createNode(2);
second->next = head;
head = second;
2. Using a node after freeing it
free returns the memory to the allocator, but the pointer variable still holds the old address. Dereferencing it afterward is a use-after-free, one of the most common sources of crashes and security bugs in C.
/* Wrong: current->data is read after the memory was freed */
free(current);
printf("%d\n", current->data);
/* Correct: save what you need before freeing */
struct Node *temp = current;
current = current->next;
free(temp);
3. Dereferencing without checking for NULL
Chasing two pointers deep, such as current->next->data, crashes with a segmentation fault whenever current->next happens to be NULL, which is exactly the case at the end of every list.
/* Wrong: crashes when current is the last node */
int peek = current->next->data;
/* Correct: check before dereferencing */
int peek = 0;
if (current->next != NULL) {
peek = current->next->data;
}
Best Practices
- Always initialize an empty list’s head pointer to
NULL, and treatNULLas a valid, expected state everywhere you write list code. - Check the return value of every
mallocbefore dereferencing it; a failed allocation returnsNULL, and writing through aNULLpointer crashes the program. - Free every node you allocate, and free them in traversal order, saving the next pointer before calling
freeon the current node. - When a function can change which node is first, such as inserting at the head or deleting the head, have it return the new head and make callers reassign it, as shown in every example above.
- If you frequently append to the end of a long list, keep a separate
tailpointer alongsideheadso appends are O(1) instead of requiring a full traversal. - Run new list code under a tool like Valgrind or AddressSanitizer while learning; leaks and use-after-free bugs are common and these tools catch them immediately.
- Draw the list on paper as boxes and arrows before writing pointer-reassignment code; most linked list bugs come from updating pointers in the wrong order and losing a reference.
Practice Exercises
- Write a function
struct Node *reverseList(struct Node *head)that reverses a singly linked list in place, using only pointer reassignment (no extra array or recursion), and returns the new head. - Write a function
int listLength(struct Node *head)that returns the number of nodes in a list, then use it to buildstruct Node *insertAtPosition(struct Node *head, int value, int position)that inserts a new node at a given zero-based position. - Write a function
struct Node *findMiddle(struct Node *head)that returns a pointer to the middle node of a list in a single pass, using two pointers that advance at different speeds (a "slow" pointer moving one node at a time and a "fast" pointer moving two).
Summary
- A linked list is a chain of individually heap-allocated nodes, each holding a value and a pointer to the next node, terminated by a
NULLpointer. - A self-referential struct, such as
struct Node { int data; struct Node *next; };, is what makes a node able to point to another node of the same type. - Compared to an array, a linked list trades O(1) random access for O(1) insertion and deletion once you are positioned at a node, at the cost of pointer-chasing overhead and one extra pointer of memory per element.
- Insertion and deletion functions typically take the current head and return the possibly-updated head, since the first node itself can change.
- Every node obtained with
mallocmust eventually be released withfree, saving the next pointer first, and freed pointers should never be dereferenced again. - Variants like the doubly linked list (a
prevpointer added) and the circular linked list (the tail points back to the head) build directly on the singly linked list covered here.
