List Data Type in Python: Complete Mutable Sequence Creation and Manipulation Implementation Guide

List Data Type in python

List Data Type in python

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:

OperationComplexityNotes
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 lstO(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:

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

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

Exit mobile version