The first time I heard about linked lists, I didn’t really understand why anyone would use them instead of Python’s built-in lists but I got to understand the true value of this data structure when I worked on an application that required to frequently add and remove elements from a collection that constantly changed in size.

In this article, I’ll guide you through implementing linked lists in Python from scratch. We’ll cover basic concepts but will also see full implementations with code examples.

What is a Linked List?

Linked lists are linear data structures where elements are stored in nodes, and each node points to the next node in the sequence. These lists are different from the built-in Python ones because they are not stored in contiguous memory locations.

We can think of linked lists as treasure hunts in which each clue points to the location of the next clue without the possibility to skip ahead.

Singly Linked Lists

There are two types of linked lists. The first one, the singly linked list, has nodes that point only to the next node in the sequence. Unlike Python’s built-in lists (contiguous memory), linked list elements can be scattered throughout memory, with each node storing data plus one reference (~8 bytes overhead per node). A singly linked list is simpler to implement but only allows forward traversal. We can represent it this way:


Figure 1. Singly linked list structure with forward-only navigation

Doubly Linked Lists

The second type, the doubly linked list, has nodes that point to both the next and previous nodes. This allows for traversal in both directions but requires double the reference memory (~16 bytes overhead per node) and more code complexity. This list type looks like this:


Figure 2. Doubly linked list structure with bidirectional navigation

Step-by-Step Implementation of a Singly Linked List

Let’s start with the implementation of a singly linked list in Python. For this, we’ll need:

  1. A Node class to represent each element
  2. A LinkedList class to manage the collection of nodes
  3. Insertion, deletion, and search methods

The Node Class

This class is super straightforward. It will allow us to store a piece of data and a reference to the next node each time we create a node.

class ListNode:     def __init__(self, value):         self.value = value  # Store the actual data         self.next_node = None  # Reference to the following node

The LinkedList Class

Now let’s build the LinkedList class with a couple of basic operations:

class SimpleLinkedList:     def __init__(self):         self.first = None  # Start with an empty list             def is_list_empty(self):         return self.first is None             def show_all_elements(self):         “””Display all values in the linked list”””         current_element = self.first         values = []                 while current_element:             values.append(str(current_element.value))             current_element = current_element.next_node                     print(” → “.join(values))

Now that we have this class to give us a basic linked list structure, we can add methods to insert, delete, and search for elements.

Insertion Methods

We can basically insert elements in three different locations within a list. Let’s add a method for each one of these:

  • At the beginning (prepend)
  • At the end (append)
  • After a specific node

def add_to_front(self, value):     “””Add a new element at the start of the list”””     fresh_node = ListNode(value)     fresh_node.next_node = self.first     self.first = fresh_node def add_to_end(self, value):     “””Add a new element at the end of the list”””     fresh_node = ListNode(value)         # Handle empty list case     if self.is_list_empty():         self.first = fresh_node         return             # Navigate to the last element     current_element = self.first     while current_element.next_node:         current_element = current_element.next_node             # Connect the new node at the end     current_element.next_node = fresh_node def add_after_value(self, target_value, new_value):     “””Add a new element after a node containing target_value”””     # Locate the target node     current_element = self.first     while current_element and current_element.value != target_value:         current_element = current_element.next_node             # Handle case where target wasn’t found     if current_element is None:         print(f”Value {target_value} not found in list”)         return             # Create and insert the new node     fresh_node = ListNode(new_value)     fresh_node.next_node = current_element.next_node     current_element.next_node = fresh_node


Figure 3. Animation demonstrating node insertion in a linked list

Deletion Methods

Obviously, we will need to have the ability to delete nodes from the lists we create. Let’s create a method to achieve this.

def remove_value(self, target_value):     “””Remove the first node containing the specified value”””     # Handle empty list     if self.is_list_empty():         print(“Cannot remove from empty list”)         return             # Handle case where first node contains target value     if self.first.value == target_value:         self.first = self.first.next_node         return             # Search for the target value     current_element = self.first     while current_element.next_node and current_element.next_node.value != target_value:         current_element = current_element.next_node             # Handle case where target wasn’t found     if current_element.next_node is None:         print(f”Value {target_value} not found in list”)         return             # Remove the target node by updating references     current_element.next_node = current_element.next_node.next_node


Figure 4. Animation demonstrating node deletion from a linked list

Search Method

It’s equally important that we are able to search for values within our lists. Let’s create a method for that too:

def find_value_position(self, target_value):     “””Find the position of a value in the list”””     current_element = self.first     index = 0         while current_element:         if current_element.value == target_value:             return index         current_element = current_element.next_node         index += 1             # Return -1 if value not found     return -1

Testing Our Implementation

Now that we’ve put together all these methods, we can finally try them out. I encourage you to follow along and play around with the methods we’ve created to get the feeling of how singly linked lists work.

# Create a new linked list instance my_linked_list = SimpleLinkedList() # Add some elements my_linked_list.add_to_end(100) my_linked_list.add_to_end(200) my_linked_list.add_to_front(50) my_linked_list.add_after_value(100, 150) # Display the current list my_linked_list.show_all_elements()  # Output: 50 → 100 → 150 → 200 # Search for a specific value position = my_linked_list.find_value_position(150) print(f”Value 150 found at position: {position}”# Position: 2 # Remove an element my_linked_list.remove_value(100) my_linked_list.show_all_elements()  # Output: 50 → 150 → 200

Implementation of a Doubly Linked List

Now that we’ve covered singly linked lists, let’s implement a doubly linked list. The main difference here is that each node will have both next and prev pointers. This doubles the memory overhead (~16 bytes vs ~8 bytes per node) but enables bidirectional traversal and more efficient operations at both ends.

For this one, let’s just jump right into the code:

class DoublyNode:     def __init__(self, value):         self.value = value         self.next_node = None  # Reference to next element         self.previous_node = None  # Reference to previous element class BidirectionalLinkedList:     def __init__(self):         self.head_node = None         self.tail_node = None  # Track both ends for efficiency             def is_empty_list(self):         return self.head_node is None             def show_forward(self):         “””Display all elements from head to tail”””         elements = []         current = self.head_node                 while current:             elements.append(str(current.value))             current = current.next_node                     print(” ↔ “.join(elements))             def show_backward(self):         “””Display all elements from tail to head”””         elements = []         current = self.tail_node                 while current:             elements.append(str(current.value))             current = current.previous_node                     print(” ↔ “.join(elements))             def add_at_end(self, value):         “””Insert a new node at the tail of the list”””         new_element = DoublyNode(value)                 # Handle empty list scenario         if self.is_empty_list():             self.head_node = new_element             self.tail_node = new_element             return                     # Connect the new node at the end         self.tail_node.next_node = new_element         new_element.previous_node = self.tail_node         self.tail_node = new_element             def add_at_beginning(self, value):         “””Insert a new node at the head of the list”””         new_element = DoublyNode(value)                 # Handle empty list scenario         if self.is_empty_list():             self.head_node = new_element             self.tail_node = new_element             return                     # Connect the new node at the beginning         new_element.next_node = self.head_node         self.head_node.previous_node = new_element         self.head_node = new_element             def remove_element(self, target_value):         “””Remove the first node containing the specified value”””         # Handle empty list         if self.is_empty_list():             print(“Cannot remove from empty list”)             return                     current = self.head_node                 # Find the target node         while current and current.value != target_value:             current = current.next_node                     # Handle case where target wasn’t found         if current is None:             print(f”Value {target_value} not found”)             return                     # Handle single node list         if self.head_node == self.tail_node:             self.head_node = None             self.tail_node = None             return                     # Handle head node removal         if current == self.head_node:             self.head_node = current.next_node             self.head_node.previous_node = None             return                     # Handle tail node removal         if current == self.tail_node:             self.tail_node = current.previous_node             self.tail_node.next_node = None             return                     # Handle middle node removal         current.previous_node.next_node = current.next_node         current.next_node.previous_node = current.previous_node

Testing Our Implementation

Now that we’ve implemented the core methods for our doubly linked list, we can put them to the test. This will help us confirm that our methods work as expected.

Feel free to follow along, experiment with different inputs, and get a sense of how a doubly linked list behaves compared to its singly linked counterpart.

# Create a new doubly linked list bidirectional_list = BidirectionalLinkedList() # Add elements bidirectional_list.add_at_end(10) bidirectional_list.add_at_end(20) bidirectional_list.add_at_beginning(5) bidirectional_list.add_at_end(30) # Display forward and backward bidirectional_list.show_forward()     # Output: 5 ↔ 10 ↔ 20 ↔ 30 bidirectional_list.show_backward()    # Output: 30 ↔ 20 ↔ 10 ↔ 5 # Test deletion operations bidirectional_list.remove_element(20) bidirectional_list.show_forward()     # Output: 5 ↔ 10 ↔ 30 bidirectional_list.remove_element(5# Remove head bidirectional_list.show_forward()     # Output: 10 ↔ 30 bidirectional_list.remove_element(30) # Remove tail  bidirectional_list.show_forward()     # Output: 10

This covers most common operations and shows how the doubly linked list correctly updates both next and prev pointers. It also demonstrates how easy it is to traverse the list in both directions, which is one of the main advantages of this structure.

Common Use Cases for Linked Lists

Linked lists aren’t the best solution for every single scenario out there but are ideal for the following:

  1. Implementing stacks and queues: Linked lists are perfect for these data structures because they allow for efficient insertion and deletion at the ends.
  2. Managing dynamic datasets: When you don’t know how many elements you’ll need in advance, linked lists can grow and shrink efficiently without reallocation.
  3. Memory allocation systems: Many memory managers use linked lists to track free memory blocks.
  4. Implementing hash tables: Linked lists are often used to handle collisions in hash tables.
  5. Building music playlists: A doubly linked list makes an excellent structure for a playlist where you need to go both forward and backward through songs.

Performance and Time Complexity

In the end, choosing the right structure comes down to performance. I had no idea of complexities back in the day but soon enouhg, I began basing most of my decisions on them.

This comparison can help you make an informed choice next time you need to figure out whether a linked list might work better for the application you’re working on.

If you’re not familiar with Big O notation:

  • O(1) means constant time (the operation takes the same amount of time regardless of the size of the data).
  • O(n) means linear time (the operation time increases in direct proportion to the size of the data).

Table 1. Time Complexity of Common Operations

OperationSingly Linked ListDoubly Linked ListPython List (Array)
AccessO(n)O(n)O(1)
Insert at beginningO(1)O(1)O(n)
Insert at endO(n) or O(1)*O(1)O(1)
Insert in middleO(n)O(n)O(n)
Delete at beginningO(1)O(1)O(n)
Delete at endO(n) or O(1)*O(1)O(1)
Delete in middleO(n)O(n)O(n)
SearchO(n)O(n)O(n)

* O(1) if we keep a tail pointer

We typically aim for O(1) operations whenever possible because they scale better and are faster under most conditions.

As we can see from the table, linked lists are particularly efficient for operations at the beginning (and end for doubly linked lists), while traditional Python lists work better at random access.

Real Debugging Experience

My first time working with linked lists was a bit sloppy. I was working on a project that required a custom data structure to manage a dynamic set of user sessions. My initial implementation seemed solid to me, but the list would occasionally lose nodes, so something was obviously not right.

After hours of debugging, I discovered I had an “off-by-one” error in my deletion method. When I removed a node, I wasn’t properly updating the references and that was causing some nodes to become orphaned. 

Here’s the buggy code I initially wrote:

# Problematic deletion method def delete_node(self, value):     current = self.first         # Bug: Not handling the case when head node contains the value     while current and current.value != value:         current = current.next_node             # Bug: Not checking if current is None after the loop     if current.next_node:         current.next_node = current.next_node.next_node  # This fails if current is None!

The fix was quite straightforward:

# Corrected deletion method def delete_node(self, value):     # Handle empty list case     if self.first is None:         return             # Handle case when head contains the target value     if self.first.value == value:         self.first = self.first.next_node         return             current = self.first     while current.next_node and current.next_node.value != value:         current = current.next_node             # Verify we found the node to delete     if current.next_node:         current.next_node = current.next_node.next_node

After this, I never had this particular issue with linked lists again. I learned the hard way to always:

  1. Handle edge cases in data structures (empty lists, operations on the first or last element)
  2. Carefully trace through the logic when working with references and pointers

Comparison to Lists in Python

Sometimes it isn’t easy to figure out whether linked lists would be a better choice over Python’s built-in lists. If you find yourself trying to figure out the correct structure, this can help you out:

Use Python lists when:

  • You need random access to elements (by index)
  • Your data size is relatively stable
  • Memory overhead is a concern (lists have less overhead per element)
  • You perform many searches

Use linked lists when:

  • You frequently insert or delete elements at the beginning
  • The size of your data structure changes significantly and frequently
  • You don’t need random access
  • You’re implementing another data structure (like a stack or queue)

In practice, I’ve found that Python’s built-in lists are sufficient for most scenarios due to their optimization. However, linked lists have great use-cases and understanding them has allowed me to create a mental model for thinking about memory, references, and data organization.

Wrapping Up

Implementing linked lists in Python is a fantastic exercise you can do to reinforce fundamental programming concepts like references, pointers, and dynamic memory management. 

As a practice challenge, I encourage you to try these exercises:

  1. Implement a method to detect cycles in a linked list
  2. Create a method to reverse a linked list in-place
  3. Implement a method to find the middle element of a linked list in one pass

Understanding linked lists will make you a better programmer, not just because you’ll occasionally need to implement them, but because they will deepen your understanding of how data structures work at a fundamental level. The skills you develop working with linked lists transfer to many other areas of programming.

If you want to deepen your knowledge of data structures and algorithms, Udacity’s Data Structures & Algorithms Nanodegree offers comprehensive training on these fundamental concepts. For a broader foundation in Python programming, check out the Programming for Data Science with Python Nanodegree. And if you’re interested in applying data structures to solve complex problems, the Full Stack Web Developer Nanodegree teaches you how to build robust applications using these principles.

Alan Sánchez Pérez Peña
Alan Sánchez Pérez Peña
Alan is a seasoned developer and a Digital Marketing expert, with over a decade of software development experience. He has executed over 70,000+ project reviews at Udacity, and his contributions to course and project development have significantly enhanced the learning platform. Additionally, he provides strategic web consulting services, leveraging his front-end expertise with HTML, CSS, and JavaScript, alongside his Python skills to assist individuals and small businesses in optimizing their digital strategies. Connect with him on LinkedIn here: http://www.linkedin.com/in/alan247