The list was the very first Python data structure I actually understood deeply, mostly because I misused it so often before I did. I appended to lists inside loops without thinking about performance, I used lists where I really needed sets, and I copied lists incorrectly more times than I’d like to admit. Understanding how Python lists actually work under the hood fixed nearly all of those habits at once.
What Is a List?
A list is an ordered, mutable collection that can hold items of any type, including a mix of types within the same list.
fruits = ["apple", "banana", "cherry"]
mixed = [1, "two", 3.0, True]
print(fruits)
print(mixed)
print(type(fruits))
Output:
['apple', 'banana', 'cherry']
[1, 'two', 3.0, True]
(class 'list')
Creating Lists
empty = []
numbers = [1, 2, 3, 4, 5]
from_range = list(range(5))
from_string = list("abc")
print(empty, numbers, from_range, from_string)
Output:
[] [1, 2, 3, 4, 5] [0, 1, 2, 3, 4] ['a', 'b', 'c']
Accessing and Slicing
fruits = ["apple", "banana", "cherry", "date"]
print(fruits[0])
print(fruits[-1])
print(fruits[1:3])
print(fruits[::-1])
Output:
apple
date
['banana', 'cherry']
['date', 'cherry', 'banana', 'apple']
Modifying Lists — Mutability in Action
Unlike tuples, lists can be changed in place after creation. This is the defining characteristic of the type.
fruits = ["apple", "banana", "cherry"]
fruits[1] = "blueberry"
print(fruits)
fruits.append("date")
print(fruits)
fruits.insert(1, "avocado")
print(fruits)
fruits.remove("cherry")
print(fruits)
popped = fruits.pop()
print(popped, fruits)
Output:
['apple', 'blueberry', 'cherry']
['apple', 'blueberry', 'cherry', 'date']
['apple', 'avocado', 'blueberry', 'cherry', 'date']
['apple', 'avocado', 'blueberry', 'date']
date ['apple', 'avocado', 'blueberry']
How Lists Actually Work Internally
CPython implements a list as a dynamic array — internally, it’s a contiguous block of memory holding pointers to the actual objects, not the objects themselves. Because it’s a contiguous array, indexing (fruits[2]) is an O(1) constant-time operation: Python just computes the memory offset directly.
Where it gets interesting is how lists handle growth. Since the underlying array has a fixed capacity at any given moment, appending an item when the array is already full requires CPython to allocate a new, larger block of memory and copy every existing pointer over. To avoid doing this on every single append, CPython over-allocates — it grows the array by more than just one slot each time it resizes, following a growth pattern roughly proportional to the current size. This is why .append() is described as having amortized O(1) time complexity: most appends are simply O(1) because there’s spare capacity, and only occasionally does an append trigger the more expensive O(n) resize-and-copy operation, but averaged out over many appends, the cost per append stays constant.
import sys
lst = []
previous_size = sys.getsizeof(lst)
for i in range(10):
lst.append(i)
current_size = sys.getsizeof(lst)
if current_size != previous_size:
print(f"After {i+1} appends, size grew to {current_size} bytes")
previous_size = current_size
Typical output (exact numbers vary by Python version):
After 1 appends, size grew to 88 bytes
After 5 appends, size grew to 120 bytes
After 9 appends, size grew to 184 bytes
Notice the size doesn’t grow by a fixed amount on every single append — it jumps in chunks, exactly as the over-allocation strategy predicts.
Operations and Their Time Complexity
Understanding the complexity of common list operations changed how I write performance-sensitive code:
| Operation | Complexity | Notes |
|---|---|---|
lst[i] (index access) | O(1) | Direct memory offset calculation |
lst.append(x) | Amortized O(1) | Occasional resize costs O(n) |
lst.pop() (from end) | O(1) | No shifting required |
lst.pop(0) (from start) | O(n) | Every remaining element must shift left |
lst.insert(0, x) | O(n) | Every existing element must shift right |
x in lst | O(n) | Linear scan through the list |
lst.sort() | O(n log n) | Uses Timsort |
import timeit
lst = list(range(100000))
pop_end = timeit.timeit(lambda: lst.pop(), number=1000, setup="pass")
This complexity table is exactly why I switched from list.pop(0) to collections.deque for queue-like operations in performance-critical automation scripts — repeatedly removing from the front of a list is quietly O(n) every single time.
from collections import deque
queue = deque([1, 2, 3, 4, 5])
queue.popleft()
print(queue)
Output:
deque([2, 3, 4, 5])
deque.popleft() runs in O(1), unlike list.pop(0).
List Comprehensions
List comprehensions are, in my opinion, one of Python’s best readability features once you get comfortable with them.
squares = [x**2 for x in range(10)]
evens = [x for x in range(20) if x % 2 == 0]
nested = [x*y for x in range(3) for y in range(3)]
print(squares)
print(evens)
print(nested)
Output:
[0, 1, 4, 9, 16, 25, 36, 49, 64, 81]
[0, 2, 4, 6, 8, 10, 12, 14, 16, 18]
[0, 0, 0, 0, 1, 2, 0, 2, 4]
List comprehensions are also generally faster than the equivalent explicit for loop with repeated .append() calls, because the comprehension is optimized internally by the interpreter with a dedicated bytecode pattern.
Copying Lists Correctly
This is a mistake I made more than once early on. Assigning a list to a new variable doesn’t copy it — it just creates a second reference to the exact same underlying list object.
original = [1, 2, 3]
reference = original
reference.append(4)
print(original)
print(reference)
Output:
[1, 2, 3, 4]
[1, 2, 3, 4]
Both variables changed because they point to the same object in memory. To actually copy a list, I use slicing, .copy(), or list():
original = [1, 2, 3]
shallow_copy = original.copy()
shallow_copy.append(4)
print(original)
print(shallow_copy)
Output:
[1, 2, 3]
[1, 2, 3, 4]
Note that .copy() creates a shallow copy — if the list contains nested mutable objects like other lists, those inner objects are still shared between the original and the copy. For fully independent nested copies, I use copy.deepcopy().
import copy
original = [[1, 2], [3, 4]]
deep = copy.deepcopy(original)
deep[0].append(99)
print(original)
print(deep)
Output:
[[1, 2], [3, 4]]
[[1, 2, 99], [3, 4]]
Sorting Lists
numbers = [5, 2, 9, 1, 7]
numbers.sort()
print(numbers)
numbers.sort(reverse=True)
print(numbers)
words = ["banana", "kiwi", "apple"]
sorted_by_length = sorted(words, key=len)
print(sorted_by_length)
Output:
[1, 2, 5, 7, 9]
[9, 7, 5, 2, 1]
['kiwi', 'apple', 'banana']
.sort() modifies the list in place and returns None; sorted() returns a brand-new list and leaves the original untouched. This distinction has bitten me before when I accidentally wrote numbers = numbers.sort() and ended up with None.
Real-World and Automation Use Cases
I reach for lists constantly in day-to-day scripting:
- Reading and processing lines from a file or CSV, where each row becomes a list element.
- Batching API requests, splitting a large list into smaller chunks.
- Building up results inside a loop, then processing them all together afterward.
def chunk_list(data, size):
return [data[i:i + size] for i in range(0, len(data), size)]
batches = chunk_list(list(range(10)), 3)
print(batches)
Output:
[[0, 1, 2], [3, 4, 5], [6, 7, 8], [9]]
Best Practices
- Prefer list comprehensions over manual
.append()loops for simple transformations, for both readability and speed. - Use
.copy()or slicing when you genuinely need an independent copy, andcopy.deepcopy()for nested mutable structures. - Avoid
list.insert(0, x)orlist.pop(0)in performance-sensitive code — usecollections.dequeinstead. - Use
sorted()when you need to preserve the original list, and.sort()when in-place modification is fine.
Common Mistakes
A classic mutable-default-argument bug catches nearly every Python developer at some point:
def add_item(item, target=[]):
target.append(item)
return target
print(add_item("a"))
print(add_item("b"))
Output:
['a']
['a', 'b']
The second call unexpectedly includes "a" from the first call, because the default list argument is created only once, when the function is defined, and then reused across every call that doesn’t supply its own. The fix is to use None as the default and create a new list inside the function body.
def add_item(item, target=None):
if target is None:
target = []
target.append(item)
return target
print(add_item("a"))
print(add_item("b"))
Output:
['a']
['b']
FAQs
What’s the difference between a list and a tuple? Lists are mutable and use []; tuples are immutable and use (). Lists are the right choice when the collection’s size or contents need to change.
Is .append() always fast? Almost always, thanks to amortized O(1) complexity from CPython’s over-allocation strategy, though individual append calls can occasionally trigger a more expensive resize.
Why is list.pop(0) slow? Because every remaining element must shift one position to fill the gap left at the front, making it an O(n) operation. Use collections.deque for efficient operations at both ends.
What’s the difference between .sort() and sorted()? .sort() sorts the list in place and returns None. sorted() returns a new sorted list, leaving the original unchanged.
How do I properly copy a list with nested lists inside it? Use copy.deepcopy(). A shallow copy via .copy() or slicing only duplicates the outer list; nested mutable objects remain shared.
Summary
Lists are Python’s dynamic array implementation — contiguous, resizable, and mutable, offering O(1) indexed access and amortized O(1) appends thanks to CPython’s over-allocation strategy under the hood. Understanding their actual complexity characteristics, not just their syntax, is what separates code that merely works from code that performs well at scale. From list comprehensions to proper copying and the classic mutable-default-argument trap, lists reward a bit of internal understanding far more than almost any other Python data type I’ve worked with.
References
- Python official documentation on lists: https://docs.python.org/3/library/stdtypes.html#list
- Python tutorial on data structures: https://docs.python.org/3/tutorial/datastructures.html
- Python Time Complexity wiki (official): https://wiki.python.org/moin/TimeComplexity
collections.dequedocumentation: https://docs.python.org/3/library/collections.html#collections.deque