Lists

The list is Python's general-purpose, ordered, mutable sequence — the workhorse container you reach for by default.

A list is an ordered, mutable sequence of objects, written with square brackets. Lists can hold any mix of object types, grow and shrink in place, and support indexing, slicing, and a rich set of in-place mutation methods. They are the default choice in Python whenever you need an ordered collection that will change over the program's lifetime.

Creating Lists

Lists are created with square-bracket literals, the list() constructor applied to any iterable, or a list comprehension. An empty list is [].

list[int] is a built-in generic subscript for type hints (Python 3.9+); it doesn't restrict what you can actually put in the list at runtime — Python's typing is enforced by external checkers like mypy, not by the interpreter.

empty: list[int] = []
numbers = [1, 2, 3, 4]
mixed = [1, "two", 3.0, [4, 5]]      # elements can be any type
from_range = list(range(5))          # [0, 1, 2, 3, 4]
from_string = list("abc")            # ['a', 'b', 'c']
squares = [n * n for n in range(6)]  # list comprehension

Indexing and Slicing

Lists support zero-based indexing, negative indices counting from the end, and slicing to extract sub-lists. A slice always returns a new list, leaving the original untouched.

Indexing out of range raises IndexError; slicing never does — an out-of-range slice just returns an empty or truncated list.

data = [10, 20, 30, 40, 50]
data[0]        # 10
data[-1]       # 50 (last element)
data[1:3]      # [20, 30]  -- stop index is exclusive
data[:2]       # [10, 20]
data[::2]      # [10, 30, 50]  -- every second element
data[::-1]     # [50, 40, 30, 20, 10]  -- reversed copy

Mutation: append, extend, insert

Lists are mutable: their contents can change in place without creating a new list object. append() adds one item to the end; extend() adds every item from an iterable; insert() places an item at a specific index.

extend(x) is equivalent to nums += x for lists, both consuming an iterable element by element; += on a list is in-place, unlike + which builds a new list.

nums = [1, 2, 3]
nums.append(4)          # [1, 2, 3, 4]
nums.extend([5, 6])     # [1, 2, 3, 4, 5, 6]
nums.insert(0, 0)       # [0, 1, 2, 3, 4, 5, 6]

# common mistake avoided: append(iterable) nests it instead of extending
nums.append([7, 8])     # [.., 6, [7, 8]]  -- a single nested list added

Removing Elements: remove, pop, clear

remove(value) deletes the first matching value (ValueError if absent). pop(index) removes and returns the element at that index, defaulting to the last element. clear() empties the list in place. del and slice assignment can also remove elements.

nums = [10, 20, 30, 20]
nums.remove(20)     # removes first 20 -> [10, 30, 20]
last = nums.pop()   # removes & returns 20 -> [10, 30]
first = nums.pop(0) # removes & returns 10 -> [30]
del nums[0]         # [] -- del by index/slice, no return value
nums = [1, 2, 3, 4]
del nums[1:3]       # [1, 4]
nums.clear()        # []

Sorting and Reversing

sort() sorts a list in place and returns None; sorted() returns a new sorted list, leaving the original unchanged. Both accept key= and reverse= keyword arguments. reverse() reverses a list in place.

Python's sort is Timsort, a stable sort: elements that compare equal keep their relative order — useful for multi-key sorts done as successive sort() calls.

nums = [3, 1, 4, 1, 5]
nums.sort()                     # in place -> [1, 1, 3, 4, 5]
words = ["banana", "kiwi", "fig"]
words.sort(key=len)             # ['fig', 'kiwi', 'banana']
words.sort(key=len, reverse=True)

original = [3, 1, 2]
new_list = sorted(original)     # original unchanged: [3, 1, 2]
nums.reverse()                  # in place reversal

Copying: copy() and Aliasing

Assignment (b = a) does not copy a list; it makes b a second name for the same object. Mutating through either name is visible through the other. To get an independent list, use copy(), the list() constructor, or a slice [:] — all of these are shallow copies.

A shallow copy only copies the outer list; if elements are themselves mutable (e.g. nested lists), the inner objects are still shared. Use copy.deepcopy() from the standard library for fully independent nested structures.

a = [1, 2, 3]
b = a            # alias: b is a -> True
b.append(4)
print(a)         # [1, 2, 3, 4]  -- a changed too!

c = a.copy()     # shallow copy, independent top-level list
c is a           # False
c.append(5)
print(a)         # unaffected: [1, 2, 3, 4]

Membership and Search

The in operator tests membership by scanning the list and comparing with ==, an O(n) operation. index() finds the position of the first match (ValueError if missing); count() counts occurrences.

nums = [10, 20, 30, 20]
20 in nums          # True
99 in nums           # False
nums.index(20)       # 1 (first match)
nums.count(20)       # 2

List Comprehensions

A list comprehension builds a new list from an iterable in a single, readable expression, optionally filtering with an if clause. It is generally faster and more idiomatic than manually appending in a for loop.

The loop variable in a comprehension has its own scope in Python 3 (unlike Python 2) and does not leak into the enclosing scope.

squares = [n * n for n in range(10)]
evens = [n for n in range(20) if n % 2 == 0]
pairs = [(x, y) for x in range(3) for y in range(2)]  # nested loops
matrix_flat = [v for row in [[1, 2], [3, 4]] for v in row]

Nested Lists and the Aliasing Trap

A common bug: building a grid with [[0] * cols] * rows creates rows that are all aliases of the same inner list, so mutating one row mutates every row.

# WRONG: all 3 rows are the SAME list object
grid = [[0] * 3] * 3
grid[0][0] = 1
print(grid)   # [[1, 0, 0], [1, 0, 0], [1, 0, 0]]  -- every row changed!

# RIGHT: a comprehension creates a fresh inner list each iteration
grid = [[0] * 3 for _ in range(3)]
grid[0][0] = 1
print(grid)   # [[1, 0, 0], [0, 0, 0], [0, 0, 0]]

Lists as Stacks and Queues

append() and pop() (no argument) make a list an efficient LIFO stack, both O(1) amortized. Using a list as a FIFO queue with pop(0) or insert(0, x) is O(n) per operation because every remaining element must shift; for queues, prefer collections.deque, covered separately.

stack = []
stack.append(1)
stack.append(2)
stack.pop()      # 2 -- LIFO order

Practical Usage

Lists are the default container for: accumulating results in a loop, representing rows of tabular data, building sequences to sort or filter, and passing ordered, mutable data between functions. When order doesn't matter and uniqueness does, prefer a set; when the collection shouldn't change after creation, prefer a tuple.

def evens_up_to(n: int) -> list[int]:
    return [x for x in range(n + 1) if x % 2 == 0]

results: list[str] = []
for line in ["a", "b", "c"]:
    results.append(line.upper())

Edge Cases

Some behaviors surprise newcomers: comparing lists with == compares elements recursively (not identity); a list can contain itself, creating a reference cycle that only the cyclic garbage collector can reclaim; mutating a list while iterating over it directly can skip or repeat elements.

# mutating during iteration -- unreliable
nums = [1, 2, 3, 4, 5]
for n in nums:
    if n % 2 == 0:
        nums.remove(n)   # skips elements! don't do this
print(nums)  # [1, 3, 5] happens to work here, but is fragile

# safe pattern: iterate over a copy, or build a new list
nums = [1, 2, 3, 4, 5]
nums = [n for n in nums if n % 2 != 0]

Complete Example: Deduplicate While Preserving Order

A set gives O(1) average membership checks, so the whole pass is O(n) rather than the O(n^2) of checking 'in result' on a list.

def dedupe(items: list[int]) -> list[int]:
    """Return items with duplicates removed, keeping first-seen order."""
    seen: set[int] = set()
    result: list[int] = []
    for item in items:
        if item not in seen:
            seen.add(item)
            result.append(item)
    return result

print(dedupe([3, 1, 3, 2, 1, 4]))  # [3, 1, 2, 4]

Mental Model and Quick Reference

Picture a list as a resizable array of references (boxes holding pointers to objects), not a row of the objects themselves. Copying the list copies the boxes, not what's inside them — that's the root of aliasing behavior.

Method       Effect                          Returns      In place?
append(x)    add x to end                    None         yes
extend(it)   add each item from it           None         yes
insert(i,x)  insert x at index i             None         yes
remove(x)    delete first x                  None         yes
pop(i=-1)    delete & return item at i       item         yes
clear()      remove all items                None         yes
sort()       sort ascending                  None         yes
sorted(lst)  sort ascending                  new list     no
reverse()    reverse order                   None         yes
copy()       shallow copy                    new list     no

Key Takeaways

Lists are ordered, mutable, heterogeneous sequences created with [] or list().

Indexing and slicing support negative indices; slicing always returns a new list.

append/extend/insert add elements; remove/pop/clear/del take them away.

sort()/reverse() mutate in place; sorted() and slicing return new lists.

Assignment aliases a list; use copy(), list(), or [:] for an independent shallow copy, and copy.deepcopy() for nested structures.

List comprehensions are the idiomatic way to build a list from an iterable.

rows = [[0] * 3] * 3 rows = [[0] * 3 for _ in range(3)] Multiplying a list of lists repeats references to the same inner list; use a comprehension so each row is a distinct object.

for x in nums: if x < 0: nums.remove(x) nums = [x for x in nums if x >= 0]

Removing from a list while iterating over it shifts indices and can skip elements; build a filtered list instead.

b = a b.append(1) # a also changes b = a.copy() b.append(1) # a is unaffected b = a creates an alias, not a copy; use copy() (or list(a), a[:]) when an independent list is needed.

nums.append([7, 8]) # nests a list nums.extend([7, 8]) # adds 7 and 8 individually append() always adds exactly one item, even if that item is itself a list; extend() unpacks an iterable's elements.

What is the difference between list.sort() and the built-in sorted() function?

sort() is a list method that sorts in place and returns None; sorted() is a built-in that works on any iterable and returns a new list, leaving the original unchanged.

Why does grid = [[0]*3]*3 produce three rows that all change together when one is mutated?

The outer * 3 repeats references to the same inner list object three times, so all three names in grid point at one shared list; use a list comprehension to create independent rows.

What's the time complexity of x in some_list and why?

O(n) on average: membership testing on a list scans elements sequentially with == until a match is found or the list is exhausted, unlike a set's O(1) average hash lookup.

Explain the difference between a = b and a = b.copy() for lists.

a = b binds a to the same list object as b (aliasing); a = b.copy() creates a new, independent top-level list with the same elements, so mutating a's structure no longer affects b.

Why is nums.pop(0) in a loop considered inefficient for large lists?

Removing from the front of a list requires shifting every remaining element left by one, making it O(n) per call; a collections.deque supports O(1) removal from both ends.

What does list('abc') produce, and why?

['a', 'b', 'c'] -- list() iterates over any iterable and collects each item, and iterating a string yields its individual characters.

Is [1, 2] == [1, 2] True, and is [1, 2] is [1, 2] also True? Explain the difference.

== is True because lists compare element-by-element for equality; is is False because they are two distinct list objects in memory, even though their contents match.

Write a function that returns the second-largest distinct value in a list of integers.

def second_largest(nums: list[int]) -> int: unique_sorted = sorted(set(nums), reverse=True) if len(unique_sorted) < 2: raise ValueError("need at least two distinct values") return unique_sorted[1] print(second_largest([4, 1, 4, 7, 7, 2])) # 4 second_largest returns 4: deduplicating with set() then sorting descending gives [7, 4, 2, 1], and index 1 is the second-largest distinct value.

What does this print, and why doesn't squares change when nums is appended to?

[1, 4, 9]. The comprehension squares = [n*n for n in nums] rebinds squares to a brand-new list object, breaking the earlier alias; appending to nums afterward has no effect on that new list.

Write a function that rotates a list left by k positions without using external libraries.

def rotate_left(items: list[int], k: int) -> list[int]: if not items: return items k %= len(items) return items[k:] + items[:k] print(rotate_left([1, 2, 3, 4, 5], 2)) # [3, 4, 5, 1, 2]

Slicing at index k and swapping the two halves rotates the list; k %= len(items) handles k larger than the list length or k == 0 safely.

nums = [1, 2, 3]
squares = nums
squares = [n * n for n in nums]
nums.append(4)
print(squares)