Data Structure 📂 Data Structures (Core) · 2 of 6 68 min read

Linked List in Python: Full Class Implementation Guide

Learn how to build a singly linked list in Python using only classes — no built-in lists. Covers Node, head/tail pointers, insert, delete, search, reverse with animated step-by-step visualisations and time complexity tables.

Section 01

The Story Behind Linked Lists

A Train of Carriages
Imagine a freight train. Each carriage carries cargo and has a hook at the back that connects to the next carriage. No carriage needs to know where it sits in the train — it only knows what it carries and which carriage comes after it.

Adding a new carriage anywhere is easy: unhook two adjacent carriages, insert the new one, re-hook. The entire rest of the train doesn't move. Removing a carriage is equally painless: unhook it and link its neighbours directly together.

But if you want to find, say, the 47th carriage, you have no choice but to walk from the locomotive, one carriage at a time. There is no shortcut. That trade-off — cheap reshuffling, expensive lookup — is the soul of a linked list.

A linked list is a sequence of nodes scattered anywhere in memory. Each node stores a value and a pointer to the next node. Unlike arrays, nodes do not need to be contiguous — the pointer chain is what gives the list its order.

🔗
The Key Mental Model

Think of each node as a sticky note with two fields: a data field (the value you care about) and a next field (an arrow pointing to the next sticky note). The list itself is just a variable called head that holds the arrow to the very first note. If head is None, the list is empty.


Section 02

Three Flavours of Linked List

➡️
Singly Linked
Each node → next only
The simplest form. Each node has one pointer: next. Traversal is one-directional — forward only. Cannot go backwards without restarting from head. Lowest memory overhead: one pointer per node.
✓ Minimal memory, simple implementation
✗ No backward traversal
↔️
Doubly Linked
Each node ⟵ prev · next ⟶
Each node carries both next and prev pointers. Traversal works in both directions. Deletion is O(1) if you already have the node reference. Used in Python's collections.deque.
✓ Bidirectional, O(1) delete with reference
✗ Double the pointer memory
🔄
Circular Linked
tail.next → head
The tail node's next points back to the head instead of None. Useful for round-robin schedulers, music playlists, and buffer rings. Can be singly or doubly circular.
✓ No null terminus, wrap-around traversal
✗ Infinite loop risk if not handled carefully
⚠️
This Tutorial Focuses on Singly Linked Lists

We implement a complete singly linked list from scratch using only Python classes — no built-in list, deque, or any other container. Every node is an object. Every operation is written explicitly so you can see exactly what happens in memory at each step.


Section 03

Interactive Diagram — See the Pointers Move

Select an operation and step through each frame. Arrows represent pointers. Orange = currently followed pointer · Green = newly written · Red = being removed · Blue = being read/traversed.

🔗 Linked List Visualiser
0 / 0
Speed

Section 04

Time Complexity — Linked List vs Array

Operation Linked List Array (dynamic) Who Wins?
Access by index O(n) O(1) Array — direct address arithmetic
Search (unsorted) O(n) O(n) Tie — both scan linearly
Prepend (insert at head) O(1) O(n) Linked List — just re-point head
Append (insert at tail) O(n)* O(1) amort. Array wins; O(1) with tail pointer
Insert in middle O(n) find + O(1) O(n) Linked List once found — no shifting
Delete head O(1) O(n) Linked List — just move head pointer
Delete middle/tail O(n) find + O(1) O(n) Linked List once found — no shifting
Memory per element data + pointer data only Array — no pointer overhead
Cache performance Poor (scattered) Excellent Array — contiguous = cache-friendly
💡
Tail Pointer Trick — O(1) Append

Walking to the tail every append is wasteful. Store a self.tail pointer alongside self.head. Append becomes: create node, set self.tail.next = node, update self.tail = node — all O(1). Our implementation below includes this optimisation.


Section 05

Full Python Implementation — Classes Only

Below is a complete singly linked list built entirely from Python classes. No list, no deque, no imports. Every node is an object. Every operation is explicit.

The Node Class

# ── Node: the building block ──────────────────────────────────
class Node:
    def __init__(self, data):
        self.data = data   # the value this node holds
        self.next = None   # pointer to next node (None = no next)

    def __repr__(self):
        return f"Node({self.data})"
USAGE
n1 = Node(10) # Node(10) n2 = Node(20) # Node(20) n1.next = n2 # 10 → 20 → None print(n1) # Node(10) print(n1.next) # Node(20) print(n1.next.next) # None
🧱
A Node Is Just Two Fields

That is the entire definition. data holds the value. next holds a reference to another Node object — or None if this is the last node. There is nothing else. The entire power of a linked list comes from chaining these tiny objects together.

The LinkedList Class — Full Implementation

# ── LinkedList: manages the chain of nodes ────────────────────
class LinkedList:

    def __init__(self):
        self.head = None   # first node (None = empty list)
        self.tail = None   # last node  (tail pointer for O(1) append)
        self.size = 0      # number of nodes

    # ── helpers ───────────────────────────────────────────────
    def __len__(self):
        return self.size

    def is_empty(self):
        return self.head is None

    def __repr__(self):
        parts, cur = [], self.head
        while cur:
            parts.append(str(cur.data))
            cur = cur.next
        return " → ".join(parts) + " → None"

    # ── prepend: insert at head — O(1) ───────────────────────
    def prepend(self, data):
        new_node      = Node(data)
        new_node.next = self.head    # new node points to old head
        self.head     = new_node     # head now points to new node
        if self.tail is None:        # first node ever inserted
            self.tail = new_node
        self.size += 1

    # ── append: insert at tail — O(1) with tail pointer ──────
    def append(self, data):
        new_node = Node(data)
        if self.tail is None:         # empty list
            self.head = self.tail = new_node
        else:
            self.tail.next = new_node  # current tail points to new node
            self.tail      = new_node  # update tail pointer
        self.size += 1

    # ── insert_after: insert after a given value — O(n) find ─
    def insert_after(self, target, data):
        current = self.head
        while current:
            if current.data == target:
                new_node      = Node(data)
                new_node.next = current.next  # bridge to rest of list
                current.next  = new_node      # predecessor links to new node
                if current is self.tail:   # inserted after old tail
                    self.tail = new_node
                self.size += 1
                return True
            current = current.next
        return False    # target not found

    # ── delete: remove node by value — O(n) ──────────────────
    def delete(self, data):
        if self.is_empty():
            return False
        if self.head.data == data:      # deleting the head
            self.head = self.head.next
            if self.head is None:         # list is now empty
                self.tail = None
            self.size -= 1
            return True
        current = self.head
        while current.next:
            if current.next.data == data:
                if current.next is self.tail:  # deleting the tail
                    self.tail = current
                current.next = current.next.next # unlink the node
                self.size -= 1
                return True
            current = current.next
        return False    # not found

    # ── search: find node by value — O(n) ────────────────────
    def search(self, data):
        current, idx = self.head, 0
        while current:
            if current.data == data:
                return idx          # return position (0-based)
            current = current.next
            idx += 1
        return -1               # not found

    # ── get: access by index — O(n) ──────────────────────────
    def get(self, index):
        if index < 0 or index >= self.size:
            raise IndexError(f"Index {index} out of range")
        current = self.head
        for _ in range(index):
            current = current.next
        return current.data

    # ── reverse: reverse in-place — O(n) ─────────────────────
    def reverse(self):
        prev, current      = None, self.head
        self.tail          = self.head
        while current:
            nxt          = current.next
            current.next = prev
            prev         = current
            current      = nxt
        self.head = prev
METHODS SUMMARY
prepend(data) → O(1) — insert at front append(data) → O(1) — insert at back (tail pointer) insert_after(val, data) → O(n) — find val, insert after it delete(data) → O(n) — find and remove first match search(data) → O(n) — return index, or -1 get(index) → O(n) — return data at index reverse() → O(n) — reverse list in-place is_empty() → O(1) — True if no nodes len(ll) → O(1) — node count (via __len__)

Section 06

Code Execution Animator — Watch Every Line Run

Each tab shows a different method executing step by step. The highlighted line is what is currently running. The chain panel updates to reflect pointer changes in real time. The variable watch tracks every live variable.

⚡ Live Code Executor
📄 Python Code
🧠 Memory & Variables
Variable Watch
Press Play or Step → to begin.
0 / 0
Speed
🔍
How to Read the Animator

The purple ▶ arrow marks the active line. In the chain panel: green border = new node created, amber border = head pointer updated, red border = marked for deletion, blue border = currently traversed, purple border = found/result. The tail label tracks the tail pointer separately from head.


Section 07

Full Demo — Putting It All Together

# ── Full demo using our LinkedList class ──────────────────────
ll = LinkedList()

# Build the list
ll.append(10)      # 10
ll.append(30)      # 10 → 30
ll.append(40)      # 10 → 30 → 40
ll.prepend(5)     # 5 → 10 → 30 → 40
ll.insert_after(10, 20)  # 5 → 10 → 20 → 30 → 40

print("List     :", ll)
print("Length   :", len(ll))
print("Head     :", ll.head.data)
print("Tail     :", ll.tail.data)
print("Search 20:", ll.search(20))   # returns index 2
print("Get [2]  :", ll.get(2))       # returns 20

# Modify
ll.delete(5)        # remove head
ll.delete(40)       # remove tail
print("After del:", ll)

# Reverse
ll.reverse()
print("Reversed :", ll)
OUTPUT
List : 5 → 10 → 20 → 30 → 40 → None Length : 5 Head : 5 Tail : 40 Search 20: 2 Get [2] : 20 After del: 10 → 20 → 30 → None Reversed : 30 → 20 → 10 → None

Section 08

6 Golden Rules for Linked Lists

🔗 Linked Lists — Non-Negotiable Rules
1
Always store a tail pointer. Without it, every append() walks the entire list — O(n) per call. With a tail pointer, append is O(1). A linked list without a tail pointer is half-implemented.
2
Never use a linked list when you need random access. get(index) on a linked list is O(n). If you access elements by index frequently, use an array. The linked list's O(1) insert/delete advantage only matters if you already have a reference to the node.
3
Guard against the empty-list edge case in every method. delete(), search(), get() — all must check self.head is None before dereferencing. A single unguarded .next on a None object gives AttributeError.
4
Keep the tail pointer consistent. Any operation that touches the first or last node must update both head and tail as needed. A stale tail pointer causes insidious bugs — appends go to the wrong place silently.
5
For O(1) delete with a reference, use a doubly linked list. Singly linked lists require finding the predecessor to delete a node, which costs O(n). If you store a reference to the node itself (as in LRU Cache), upgrade to doubly linked — Python's collections.deque is one.
6
Prefer linked lists over arrays when: you need O(1) prepend, you insert/delete at the head or middle far more than you read by index, or your data grows unboundedly and you want zero reallocation pauses. Classic use cases: undo stacks, browser history, LRU caches, task queues.