Python Lists

A list is Python’s built-in, ordered, mutable collection of items. You can store numbers, strings, other lists, or any mix of object types in one list, and you can grow, shrink, or rearrange it after it’s created. Lists are one of the most-used data structures in Python because nearly every program needs to hold a sequence of things — rows from a file, results of a calculation, items in a queue — and lists make that easy and efficient.

Overview / How Lists Work

A list is written as a comma-separated sequence of values inside square brackets: [1, 2, 3]. Internally, a Python list is implemented as a dynamic array of references to objects, not the objects themselves. Each slot in the list stores a pointer to wherever the actual object lives in memory. This is why a single list can hold values of different types — [1, "two", 3.0, [4, 5]] is perfectly legal — because every slot is just a reference, regardless of what it points to.

Because a list stores references, copying a list variable with = does not copy the data — it copies the reference to the same underlying list object. Two variables can therefore point to the exact same list, and changes made through one are visible through the other. This behavior (called aliasing) is one of the most common sources of bugs for newcomers, and is covered in the Common Mistakes section below.

Lists are mutable, meaning you can change their contents in place — add items, remove items, or overwrite an element — without creating a new list object. This is different from immutable sequence types like tuple or str, which can’t be changed after creation. Being mutable also means lists are not hashable, so you can’t use a list as a dictionary key or put one inside a set.

Under the hood, CPython over-allocates space when a list grows, so that repeated append() calls are usually O(1) on average rather than requiring a full copy every time. Inserting or deleting at the front of a list, however, is O(n) because every following element has to shift over — something worth remembering if you’re processing large lists in a hot loop.

Syntax

The general forms for creating and accessing a list:

my_list = [item1, item2, item3]      # literal creation
empty = []                            # empty list
from_range = list(range(5))           # list() constructor from an iterable
value = my_list[index]                # indexing (0-based)
sub_list = my_list[start:stop:step]   # slicing
Part Meaning
[ ] Square brackets define a list literal or perform indexing/slicing
index Zero-based position; negative indices count from the end (-1 is the last item)
start:stop:step Slice bounds — start is inclusive, stop is exclusive, step defaults to 1
list(iterable) Builds a new list from any iterable (string, tuple, range, generator, …)

Common list methods

Method Effect
append(x) Adds x to the end of the list
insert(i, x) Inserts x before index i
extend(iterable) Appends every item from iterable
remove(x) Removes the first item equal to x (raises ValueError if absent)
pop(i=-1) Removes and returns the item at index i (default: last item)
sort() Sorts the list in place
reverse() Reverses the list in place
copy() Returns a shallow copy of the list
index(x) Returns the index of the first item equal to x
count(x) Counts how many times x appears

Examples

Example 1: Creating and modifying a list

fruits = ["apple", "banana", "cherry"]
print(fruits)
print(f"First fruit: {fruits[0]}")
print(f"Last fruit: {fruits[-1]}")

fruits.append("date")
fruits.insert(1, "blueberry")
print(fruits)

fruits.remove("banana")
popped = fruits.pop()
print(f"Popped: {popped}")
print(fruits)
print(f"Number of fruits: {len(fruits)}")

Output:

['apple', 'banana', 'cherry']
First fruit: apple
Last fruit: cherry
['apple', 'blueberry', 'banana', 'cherry', 'date']
Popped: date
['apple', 'blueberry', 'cherry']
Number of fruits: 3

This example shows the basic lifecycle of a list: create it, read from it by index (including the handy negative index -1 for “last item”), and then mutate it with append(), insert(), remove(), and pop(). Notice that remove() deletes by value while pop() deletes by position and also hands the removed value back to you.

Example 2: Slicing, filtering, and sorting

scores = [55, 82, 91, 47, 73, 100, 64]

top_three = sorted(scores, reverse=True)[:3]
print(f"Top three scores: {top_three}")

passing = [s for s in scores if s >= 60]
print(f"Passing scores: {passing}")

scores[0:2] = [60, 85]
print(f"Updated scores: {scores}")

scores.sort()
print(f"Sorted ascending: {scores}")

average = sum(scores) / len(scores)
print(f"Average: {average:.2f}")

Output:

Top three scores: [100, 91, 82]
Passing scores: [82, 91, 73, 100, 64]
Updated scores: [60, 85, 91, 47, 73, 100, 64]
Sorted ascending: [47, 60, 64, 73, 85, 91, 100]
Average: 74.29

sorted(scores, reverse=True) returns a brand-new list without touching the original, so slicing [:3] off of it is safe. The list comprehension [s for s in scores if s >= 60] builds a filtered list in one line. Assigning to the slice scores[0:2] replaces the first two elements in place — slice assignment can even change the length of the list if the replacement has a different number of items. Finally, scores.sort() sorts in place and returns None, which is why the sorted list is printed on the next line rather than by wrapping sort() itself in print().

Example 3: Nested lists and comprehensions

matrix = [
    [1, 2, 3],
    [4, 5, 6],
    [7, 8, 9],
]

flattened = [value for row in matrix for value in row]
print(f"Flattened: {flattened}")

doubled = [[value * 2 for value in row] for row in matrix]
print(f"Doubled matrix: {doubled}")

transposed = [[row[i] for row in matrix] for i in range(len(matrix[0]))]
print(f"Transposed: {transposed}")

column_sums = [sum(row[i] for row in matrix) for i in range(len(matrix[0]))]
print(f"Column sums: {column_sums}")

Output:

Flattened: [1, 2, 3, 4, 5, 6, 7, 8, 9]
Doubled matrix: [[2, 4, 6], [8, 10, 12], [14, 16, 18]]
Transposed: [[1, 4, 7], [2, 5, 8], [3, 6, 9]]
Column sums: [12, 15, 18]

A list of lists is a natural way to represent a grid or matrix. A nested comprehension like [value for row in matrix for value in row] reads left-to-right the same way nested for loops would: the outer for row in matrix runs first, and for each row the inner for value in row runs. The transpose example builds a new row for every column index, picking the i-th element out of each original row — a classic pattern for swapping rows and columns without a library.

Under the Hood

When Python executes my_list.append(x), it doesn’t create a new list — it mutates the existing array of references and, if there isn’t enough spare capacity, asks the memory allocator for a bigger block and copies the existing pointers into it (over-allocating extra room for future growth). This is why appending to a list is usually fast: most of the time there’s already spare capacity and no copying is needed.

Indexing (my_list[i]) is O(1) because a list is a true array of pointers — Python can jump straight to slot i without walking through the list. Searching by value (x in my_list, or .index(x)) is O(n) because Python has to check elements one at a time until it finds a match or reaches the end.

Slicing (my_list[a:b]) always creates a new list object containing copies of the references in that range — the original list is untouched. This is different from slice assignment (my_list[a:b] = [...]), which mutates the original list in place, potentially changing its length.

Common Mistakes

Mistake 1: Using a mutable default argument

A classic Python trap is giving a function a list as a default argument value. Default argument values are evaluated once, when the function is defined — not each time it’s called — so every call that doesn’t supply its own list ends up sharing and mutating the same one.

def add_item(item, basket=[]):
    basket.append(item)
    return basket

print(add_item("apple"))
print(add_item("banana"))

Output:

['apple']
['apple', 'banana']

The second call unexpectedly still contains "apple" because both calls reused the same default list object. The fix is to default to None and create a fresh list inside the function body:

def add_item(item, basket=None):
    if basket is None:
        basket = []
    basket.append(item)
    return basket

print(add_item("apple"))
print(add_item("banana"))

Output:

['apple']
['banana']

Mistake 2: Assuming = copies a list

Because list variables hold references, assigning one list variable to another does not duplicate the data — both names point to the same list in memory.

original = [1, 2, 3]
alias = original
alias.append(4)
print(original)

Output:

[1, 2, 3, 4]

Even though only alias was modified, original changed too, because they’re the same object. To get an independent copy, use .copy(), a full slice [:], or list(original):

original = [1, 2, 3]
copy = original.copy()
copy.append(4)
print(original)
print(copy)

Output:

[1, 2, 3]
[1, 2, 3, 4]

Note that .copy() is a shallow copy — if the list contains other mutable objects (like nested lists), those inner objects are still shared between the original and the copy. Use copy.deepcopy() from the standard library when you need a fully independent nested structure.

Best Practices

  • Use list comprehensions ([x for x in items if condition]) instead of building a list with a manual loop and repeated append() calls — they’re faster and more readable for simple transformations.
  • Prefer in for membership tests (if x in my_list) over manually looping, but remember it’s O(n) — if you’re doing many membership checks on a large collection, consider a set instead.
  • Never mutate a list default argument; default to None and create the list inside the function.
  • Use enumerate(my_list) instead of range(len(my_list)) when you need both the index and the value.
  • Use my_list.copy(), my_list[:], or list(my_list) whenever you need an independent list rather than another reference to the same one.
  • Prefer extend() over repeated append() in a loop when merging two lists together — it’s clearer and slightly faster.
  • Avoid modifying a list while iterating over it directly; iterate over a copy (for item in my_list[:]) or build a new list instead.
  • Use tuples instead of lists for fixed collections of values that shouldn’t change — it communicates intent and can be a small performance win.

Practice Exercises

Exercise 1: Given numbers = [4, 9, 1, 7, 3, 9, 2], write code that prints the list sorted in ascending order, the largest value, and how many times 9 appears — without modifying the original numbers list.

Exercise 2: Write a list comprehension that takes words = ["kiwi", "fig", "banana", "pear", "apple"] and produces a new list containing only the words with more than 4 letters, converted to uppercase. Expected output: ['BANANA', 'APPLE'].

Exercise 3: Given a nested list grades = [[85, 90], [70, 65], [100, 95]] representing two test scores per student, write code that prints the average score for each student as a new list of floats.

Summary

  • A list is an ordered, mutable collection created with square brackets, e.g. [1, 2, 3].
  • Lists store references to objects, not the objects themselves — assigning a list to another variable creates an alias, not a copy.
  • Indexing is O(1); searching by value is O(n); appending is usually O(1) thanks to over-allocation.
  • Slicing (list[a:b]) creates a new list; slice assignment mutates the original in place.
  • Use .copy(), [:], or list(x) to make an independent copy; use copy.deepcopy() for nested structures.
  • List comprehensions are the idiomatic, efficient way to build filtered or transformed lists.
  • Never use a mutable object as a default function argument.