Python Lists: Complete Guide with Examples

Lists are Python's most versatile built-in data structure. They're ordered, mutable collections that can hold heterogeneous elements. Unlike arrays in statically-typed languages, Python lists can mix...

Key Insights

  • Lists are Python’s workhorse data structure for ordered, mutable collections, but choosing between lists, tuples, and sets impacts both performance and code clarity
  • List comprehensions aren’t just syntactic sugar—they’re faster than loops and should be your default for transforming data, but nested comprehensions quickly become unreadable
  • The biggest gotcha with lists is their mutability: default arguments, shallow copies, and reference semantics cause bugs that even experienced developers overlook

Introduction to Python Lists

Lists are Python’s most versatile built-in data structure. They’re ordered, mutable collections that can hold heterogeneous elements. Unlike arrays in statically-typed languages, Python lists can mix types freely, though this flexibility comes with performance tradeoffs.

Use lists when you need an ordered collection that changes over time. If your data is immutable, use tuples instead—they’re faster and hashable. If you need uniqueness and don’t care about order, sets are more appropriate. If you’re doing numerical computation, NumPy arrays blow lists out of the water.

# Basic list creation
numbers = [1, 2, 3, 4, 5]
mixed = [1, "hello", 3.14, True, None]
empty = []

print(type(numbers))  # <class 'list'>
print(len(mixed))     # 5

Creating and Accessing Lists

Python offers multiple ways to create lists, each with different use cases:

# Literal syntax (most common)
fruits = ["apple", "banana", "cherry"]

# list() constructor from iterable
chars = list("hello")  # ['h', 'e', 'l', 'l', 'o']
range_list = list(range(5))  # [0, 1, 2, 3, 4]

# List comprehension (covered in detail later)
squares = [x**2 for x in range(5)]  # [0, 1, 4, 9, 16]

# Pre-allocated list with repeated values
zeros = [0] * 5  # [0, 0, 0, 0, 0]

Indexing and slicing are fundamental operations. Python uses zero-based indexing and supports negative indices to count from the end:

items = ["a", "b", "c", "d", "e"]

# Positive indexing
print(items[0])   # "a" (first element)
print(items[2])   # "c"

# Negative indexing
print(items[-1])  # "e" (last element)
print(items[-2])  # "d"

# Slicing: [start:stop:step]
print(items[1:4])    # ["b", "c", "d"] (stop is exclusive)
print(items[:3])     # ["a", "b", "c"] (from beginning)
print(items[2:])     # ["c", "d", "e"] (to end)
print(items[::2])    # ["a", "c", "e"] (every second element)
print(items[::-1])   # ["e", "d", "c", "b", "a"] (reverse)

Slicing returns a new list (shallow copy), while indexing returns a reference to the element. This distinction matters when dealing with nested lists.

Modifying Lists

Lists are mutable, meaning you can change them in place. This is both powerful and dangerous:

# Adding elements
fruits = ["apple", "banana"]
fruits.append("cherry")        # Add to end: ["apple", "banana", "cherry"]
fruits.insert(1, "blueberry")  # Insert at index: ["apple", "blueberry", "banana", "cherry"]
fruits.extend(["date", "fig"]) # Add multiple: ["apple", "blueberry", "banana", "cherry", "date", "fig"]

# Removing elements
fruits.remove("banana")  # Remove first occurrence
last = fruits.pop()      # Remove and return last element
second = fruits.pop(1)   # Remove and return element at index 1
del fruits[0]            # Delete by index (no return value)

# Updating values
fruits[0] = "apricot"
fruits[1:3] = ["kiwi", "lemon"]  # Replace slice with new elements

The difference between append() and extend() trips up beginners:

list1 = [1, 2, 3]
list1.append([4, 5])    # [1, 2, 3, [4, 5]] (nested list)

list2 = [1, 2, 3]
list2.extend([4, 5])    # [1, 2, 3, 4, 5] (flattened)

List Methods and Operations

Python lists come with useful methods for common operations:

numbers = [3, 1, 4, 1, 5, 9, 2, 6, 5]

# Sorting
numbers.sort()              # In-place sort: [1, 1, 2, 3, 4, 5, 5, 6, 9]
sorted_copy = sorted(numbers)  # Returns new sorted list

numbers.reverse()           # In-place reverse
reversed_copy = numbers[::-1]  # Returns new reversed list

# Searching and counting
print(numbers.count(5))     # 2 (occurrences)
print(numbers.index(4))     # 2 (first index of value)

# Clearing
numbers.clear()             # Empty the list

List operators provide intuitive syntax for common operations:

# Concatenation
list1 = [1, 2, 3]
list2 = [4, 5, 6]
combined = list1 + list2  # [1, 2, 3, 4, 5, 6]

# Repetition
repeated = [0] * 3  # [0, 0, 0]

# Membership testing
print(2 in list1)      # True
print(10 not in list1) # True

The shallow vs deep copy distinction is critical when working with nested lists:

import copy

original = [[1, 2], [3, 4]]

# Shallow copy (only copies outer list)
shallow = original.copy()  # or original[:]
shallow[0][0] = 99
print(original)  # [[99, 2], [3, 4]] - MODIFIED!

# Deep copy (recursively copies all nested objects)
original = [[1, 2], [3, 4]]
deep = copy.deepcopy(original)
deep[0][0] = 99
print(original)  # [[1, 2], [3, 4]] - unchanged

List Comprehensions

List comprehensions are the Pythonic way to create lists. They’re faster than equivalent loops and more readable once you’re familiar with the syntax:

# Traditional loop
squares = []
for x in range(10):
    squares.append(x**2)

# List comprehension (preferred)
squares = [x**2 for x in range(10)]

# With conditional filtering
evens = [x for x in range(20) if x % 2 == 0]

# With if-else (note different syntax)
labels = ["even" if x % 2 == 0 else "odd" for x in range(5)]
# ["even", "odd", "even", "odd", "even"]

# Nested comprehensions (use sparingly)
matrix = [[i + j for j in range(3)] for i in range(3)]
# [[0, 1, 2], [1, 2, 3], [2, 3, 4]]

# Flattening nested lists
nested = [[1, 2], [3, 4], [5, 6]]
flat = [item for sublist in nested for item in sublist]
# [1, 2, 3, 4, 5, 6]

List comprehensions can become unreadable quickly. If you need more than two levels of nesting or complex conditionals, use regular loops for clarity.

Advanced Techniques

Unpacking makes working with lists more elegant:

# Basic unpacking
first, second, third = [1, 2, 3]

# Extended unpacking with * operator
first, *middle, last = [1, 2, 3, 4, 5]
print(middle)  # [2, 3, 4]

# Swapping without temp variable
a, b = [1, 2]
a, b = b, a  # a=2, b=1

enumerate() and zip() are indispensable for iteration:

# enumerate() provides index and value
fruits = ["apple", "banana", "cherry"]
for index, fruit in enumerate(fruits):
    print(f"{index}: {fruit}")

# Start counting from 1 instead of 0
for index, fruit in enumerate(fruits, start=1):
    print(f"{index}. {fruit}")

# zip() combines multiple iterables
names = ["Alice", "Bob", "Charlie"]
ages = [25, 30, 35]
cities = ["NYC", "LA", "Chicago"]

for name, age, city in zip(names, ages, cities):
    print(f"{name} is {age} and lives in {city}")

# Creating dictionaries with zip
person_dict = dict(zip(names, ages))
# {"Alice": 25, "Bob": 30, "Charlie": 35}

Custom sorting with key functions unlocks powerful sorting capabilities:

# Sort by string length
words = ["python", "is", "awesome", "and", "powerful"]
words.sort(key=len)  # ["is", "and", "python", "awesome", "powerful"]

# Sort complex objects
students = [
    {"name": "Alice", "grade": 85},
    {"name": "Bob", "grade": 92},
    {"name": "Charlie", "grade": 78}
]
students.sort(key=lambda s: s["grade"], reverse=True)

# Multiple sort criteria (sort by grade, then name)
students.sort(key=lambda s: (-s["grade"], s["name"]))

Common Pitfalls and Best Practices

The mutable default argument bug catches everyone at least once:

# WRONG - default list is created once and shared
def add_item(item, items=[]):
    items.append(item)
    return items

list1 = add_item(1)  # [1]
list2 = add_item(2)  # [1, 2] - UNEXPECTED!

# CORRECT - use None as default
def add_item(item, items=None):
    if items is None:
        items = []
    items.append(item)
    return items

Be mindful of when you’re copying vs referencing:

# Reference (both variables point to same list)
list1 = [1, 2, 3]
list2 = list1
list2.append(4)
print(list1)  # [1, 2, 3, 4] - modified!

# Copy (separate lists)
list1 = [1, 2, 3]
list2 = list1.copy()  # or list1[:] or list(list1)
list2.append(4)
print(list1)  # [1, 2, 3] - unchanged

For large datasets or infinite sequences, use generators instead of lists to save memory:

# List - loads everything into memory
squares_list = [x**2 for x in range(1000000)]

# Generator - computes on demand
squares_gen = (x**2 for x in range(1000000))

# Use generators when you only need to iterate once
sum_of_squares = sum(x**2 for x in range(1000000))

Lists have O(1) append and O(n) insert/delete at arbitrary positions. If you’re doing many insertions at the beginning, consider collections.deque. For membership testing, sets are O(1) vs lists’ O(n).

Master these list fundamentals and you’ll write cleaner, faster Python code. Lists are everywhere in Python—understanding their behavior deeply pays dividends in every project you build.

Liked this? There's more.

Every week: one practical technique, explained simply, with code you can use immediately.