The Story That Explains Lists
A Python list is that row of lockers. Each slot has a numbered position (an index), and each slot holds one thing. Unlike lockers, Python's list can hold anything in each slot — a number, a string, another list, a function, even different types mixed together. And the row can grow or shrink whenever you like.
But here's the twist that makes lists so powerful: the lockers don't actually store the photos themselves — they store tickets telling you where the photos are stored. That indirection is why Python lists are so flexible, and it's the key to understanding how they work in memory.
A list (list) is Python's built-in ordered, mutable sequence type. Ordered means each element has a position; mutable means you can change, add, or remove elements after creation. Lists are the most-used collection type in Python — you'll reach for one dozens of times a day.
Creating Lists — Four Ways
# ─── 1. LITERAL SYNTAX (most common) ────────────────────
empty = []
numbers = [1, 2, 3, 4, 5]
mixed = [42, "hello", 3.14, True, None]
nested = [[1, 2], [3, 4], [5, 6]]
# ─── 2. list() CONSTRUCTOR ──────────────────────────────
from_str = list("hello") # ['h', 'e', 'l', 'l', 'o']
from_range = list(range(5)) # [0, 1, 2, 3, 4]
from_tuple = list((10, 20, 30)) # [10, 20, 30]
# ─── 3. REPETITION (quick uniform lists) ────────────────
zeros = [0] * 5 # [0, 0, 0, 0, 0]
grid_row = ["."] * 10 # ['.', '.', ... 10 dots]
# ─── 4. LIST COMPREHENSION (powerful shortcut) ──────────
squares = [x**2 for x in range(1, 6)] # [1, 4, 9, 16, 25]
evens = [x for x in range(20) if x % 2 == 0]
print(mixed) # [42, 'hello', 3.14, True, None]
print(squares) # [1, 4, 9, 16, 25]
print(evens) # [0, 2, 4, 6, 8, 10, 12, 14, 16, 18]
[[0]*3]*3 looks like a 3×3 grid — but it's a trap. All three inner lists are the same object, so modifying one modifies all three. Use [[0]*3 for _ in range(3)] instead — a comprehension makes independent inner lists.
How Lists Are Stored in Memory
This is the concept that makes lists click. When you write nums = [10, 20, 30], Python does NOT store the numbers 10, 20, 30 directly inside the list. Instead, the list holds a compact array of references (pointers) to those numbers, which live independently elsewhere in memory. This design is what lets one list hold different types at once.
The Visual Model
Layer 1 — the variable nums is a sticky
note pointing at the list object.
Layer 2 — the list object is a compact array of slots, each
holding a reference (pointer) to an actual value.
Layer 3 — the values (10, 20, 30) live independently in the heap.
They are shared with other lists and variables that reference the same values.
Why This Matters — A Mixed-Type List
Because the list only stores references (all of which are the same size — one machine word), the values themselves can be any type and any size: a tiny int, a giant string, another list. This is impossible in C or Java arrays.
Consequences of the Reference Model
# ─── PROOF: A LIST STORES REFERENCES ────────────────────
value = 1000 # object outside the small-int cache
data = [value, value, value]
# All three slots reference the SAME object
print(id(data[0]) == id(data[1])) # True
print(id(data[0]) == id(value)) # True
# ─── PROOF: ALIASING (shared reference) ─────────────────
a = [1, 2, 3]
b = a # b is NOT a copy — same list!
b.append(4) # modifies THE list
print(a) # [1, 2, 3, 4] — a sees it too!
print(a is b) # True — same object
# ─── PROPER COPY ────────────────────────────────────────
c = a.copy() # new list, same references inside
print(a is c) # False — independent lists
Indexing — Reaching Individual Elements
Since lists are ordered sequences, every element has a numbered position. Python uses zero-based indexing and supports negative indices to count from the end.
# ─── POSITIVE INDEXING ──────────────────────────────────
colors = ["red", "green", "blue", "yellow", "purple"]
print(colors[0]) # red — first element
print(colors[2]) # blue — third element
print(colors[4]) # purple — last element
# ─── NEGATIVE INDEXING ──────────────────────────────────
print(colors[-1]) # purple — last (short & sweet)
print(colors[-2]) # yellow — second to last
print(colors[-5]) # red — also the first
# ─── MUTATION VIA INDEX (lists are mutable!) ────────────
colors[1] = "forest green"
print(colors) # ['red', 'forest green', 'blue', 'yellow', 'purple']
# ─── OUT-OF-RANGE CRASHES ───────────────────────────────
# colors[10] → IndexError: list index out of range
# colors[-10] → IndexError: list index out of range
# ─── LEN() ──────────────────────────────────────────────
print(len(colors)) # 5
print(colors[len(colors) - 1]) # purple — but colors[-1] is cleaner!
Slicing — Grabbing Sub-Lists
Slicing extracts a portion of a list into a new list. Same syntax as strings: lst[start:stop:step]. All three parts are optional. Unlike indexing, slicing never crashes on out-of-range values — it just returns what's available.
# ─── BASIC SLICING ──────────────────────────────────────
nums = [10, 20, 30, 40, 50, 60, 70, 80]
# 0 1 2 3 4 5 6 7
print(nums[2:5]) # [30, 40, 50] indices 2,3,4 (NOT 5!)
print(nums[:3]) # [10, 20, 30] first three
print(nums[5:]) # [60, 70, 80] from index 5 to end
print(nums[:]) # [10, ..., 80] shallow copy of whole list
# ─── NEGATIVE INDICES IN SLICES ─────────────────────────
print(nums[-3:]) # [60, 70, 80] last three
print(nums[:-2]) # [10, ..., 60] drop last two
print(nums[-5:-2]) # [40, 50, 60] middle chunk from the end
# ─── STEP: EVERY Nth ELEMENT ────────────────────────────
print(nums[::2]) # [10, 30, 50, 70] every 2nd
print(nums[1::2]) # [20, 40, 60, 80] every 2nd starting at 1
# ─── REVERSE THE ENTIRE LIST ────────────────────────────
print(nums[::-1]) # [80, 70, 60, 50, 40, 30, 20, 10]
# ─── OUT-OF-RANGE = SAFE (no crash!) ────────────────────
print(nums[100:200]) # [] — empty list, no error
print(nums[5:100]) # [60, 70, 80] — takes what's available
# ─── SLICE ASSIGNMENT (mutation via slice!) ─────────────
data = [1, 2, 3, 4, 5]
data[1:4] = [99, 99] # replace 3 elements with 2
print(data) # [1, 99, 99, 5]
Slicing Cheat Sheet
| Pattern | Meaning | Example on [10, 20, 30, 40, 50] |
|---|---|---|
| lst[a:b] | Indices a through b-1 | lst[1:4] → [20, 30, 40] |
| lst[:n] | First n elements | lst[:3] → [10, 20, 30] |
| lst[n:] | From index n to end | lst[3:] → [40, 50] |
| lst[-n:] | Last n elements | lst[-2:] → [40, 50] |
| lst[:-n] | Everything except last n | lst[:-2] → [10, 20, 30] |
| lst[::-1] | Reverse the entire list | lst[::-1] → [50, 40, 30, 20, 10] |
| lst[::2] | Every 2nd element | lst[::2] → [10, 30, 50] |
| lst[:] | Shallow copy of the list | lst[:] → [10, 20, 30, 40, 50] |
List Methods — The Essential Toolkit
Lists come with 11 built-in methods. They fall into three categories: mutators (change the list in place, return None), queries (return information about the list), and builders (produce a copy).
Mutators — Change the List In Place
# ─── append(x) — add one item to the end ────────────────
tasks = ["code", "test"]
tasks.append("deploy")
print(tasks) # ['code', 'test', 'deploy']
# ─── extend(iterable) — add many items ──────────────────
tasks.extend(["review", "ship"])
print(tasks) # ['code', 'test', 'deploy', 'review', 'ship']
# COMPARE: append vs extend
a = [1, 2]
a.append([3, 4]) # adds ONE item (which happens to be a list)
print(a) # [1, 2, [3, 4]]
b = [1, 2]
b.extend([3, 4]) # adds each item individually
print(b) # [1, 2, 3, 4]
# ─── insert(i, x) — put x at index i ────────────────────
letters = ["a", "c", "d"]
letters.insert(1, "b") # put 'b' at index 1
print(letters) # ['a', 'b', 'c', 'd']
# ─── remove(x) — delete first occurrence of x ───────────
nums = [1, 2, 3, 2, 1]
nums.remove(2) # removes the FIRST 2 only
print(nums) # [1, 3, 2, 1]
# nums.remove(99) → ValueError: not in list
# ─── pop(i=-1) — remove & return item at index i ────────
stack = [10, 20, 30, 40]
last = stack.pop() # default = last element
print(last, stack) # 40 [10, 20, 30]
first = stack.pop(0) # pop from front (slow-ish for big lists)
print(first, stack) # 10 [20, 30]
# ─── sort() — sort in place (returns None!) ─────────────
data = [3, 1, 4, 1, 5, 9, 2, 6]
data.sort() # ascending by default
print(data) # [1, 1, 2, 3, 4, 5, 6, 9]
data.sort(reverse=True) # descending
print(data) # [9, 6, 5, 4, 3, 2, 1, 1]
words = ["apple", "kiwi", "strawberry"]
words.sort(key=len) # sort by length
print(words) # ['kiwi', 'apple', 'strawberry']
# ─── reverse() — flip in place ──────────────────────────
seq = [1, 2, 3, 4]
seq.reverse()
print(seq) # [4, 3, 2, 1]
# ─── clear() — empty the list ───────────────────────────
seq.clear()
print(seq) # []
A classic trap: nums = nums.sort() silently makes nums equal to None. Mutators change the list in place and return None. Either call them alone (nums.sort()) or use the builder equivalents (sorted(nums), reversed(nums)) which return a new object.
Queries — Return Information
# ─── count(x) — how many times x appears ────────────────
votes = ["yes", "no", "yes", "yes", "no"]
print(votes.count("yes")) # 3
print(votes.count("maybe")) # 0 (safe — no error)
# ─── index(x) — position of FIRST occurrence ────────────
print(votes.index("no")) # 1
# votes.index("maybe") → ValueError: not in list
# Safe index check with 'in' first
if "no" in votes:
print(f"Found at {votes.index('no')}")
# ─── LEN, MIN, MAX, SUM (built-ins, not methods) ────────
scores = [88, 75, 92, 60, 83]
print(len(scores)) # 5
print(min(scores), max(scores)) # 60 92
print(sum(scores)) # 398
print(sum(scores) / len(scores)) # 79.6
Builders — Return a New List
# ─── copy() — shallow copy of the list ──────────────────
original = [1, 2, 3]
duplicate = original.copy() # same as original[:]
duplicate.append(4)
print(original) # [1, 2, 3] untouched
print(duplicate) # [1, 2, 3, 4] independent copy
# ─── sorted() & reversed() — the safe builders ──────────
# These are BUILT-IN FUNCTIONS, not methods, and they return NEW objects
data = [3, 1, 2]
print(sorted(data)) # [1, 2, 3] (new list)
print(data) # [3, 1, 2] (original unchanged)
print(list(reversed(data))) # [2, 1, 3]
Complete Methods Reference
| Category | Method | What It Does | Returns |
|---|---|---|---|
| Mutators | .append(x) | Add one item to the end | None |
| .extend(iter) | Add each item from iterable | None | |
| .insert(i, x) | Insert x at index i | None | |
| .remove(x) | Delete first occurrence of x | None | |
| .pop([i]) | Remove & return item at index i (default -1) | the item | |
| .sort(key, reverse) | Sort in place | None | |
| .reverse() | Reverse in place | None | |
| Cleanup | .clear() | Remove all elements | None |
| del lst[i] | Delete element(s) by index or slice | None | |
| Queries | .count(x) | Number of occurrences | int |
| .index(x) | Position of first occurrence | int or ValueError | |
| Builder | .copy() | Shallow copy of the list | new list |
The for Loop — Iterating Over Lists
The for loop is how you walk through a list, one element at a time. Python's for is fundamentally a for-each loop — it iterates over the elements directly, not over indices like C. This makes it read like English.
Pattern 1 — Iterate Over Values (the default)
fruits = ["apple", "banana", "cherry"]
for fruit in fruits:
print(f"I love {fruit}")
# OUTPUT:
# I love apple
# I love banana
# I love cherry
Pattern 2 — Iterate With Index Using enumerate()
# enumerate() gives you (index, value) pairs — the Pythonic way
fruits = ["apple", "banana", "cherry"]
for i, fruit in enumerate(fruits):
print(f"{i}: {fruit}")
# OUTPUT:
# 0: apple
# 1: banana
# 2: cherry
# Start counting from 1 instead of 0
for num, fruit in enumerate(fruits, start=1):
print(f"Item #{num}: {fruit}")
| Code |
|---|
| for i in range(len(fruits)): |
| print(i, fruits[i]) |
| Works, but ugly. Requires len() and indexing. |
| Code |
|---|
| for i, fruit in enumerate(fruits): |
| print(i, fruit) |
| Clean, fast, self-documenting. |
Pattern 3 — Iterate Over Two Lists With zip()
names = ["Alice", "Bob", "Carol"]
scores = [92, 85, 78]
for name, score in zip(names, scores):
print(f"{name}: {score}")
# OUTPUT:
# Alice: 92
# Bob: 85
# Carol: 78
# zip stops at the shortest list
short = ["x", "y"]
long = [1, 2, 3, 4]
for a, b in zip(short, long):
print(a, b) # only 2 iterations
Pattern 4 — Building a New List From a For Loop
numbers = [1, 2, 3, 4, 5]
# Explicit for loop
squares = []
for n in numbers:
squares.append(n * n)
print(squares) # [1, 4, 9, 16, 25]
# Same result — one-line comprehension (more Pythonic for simple cases)
squares = [n * n for n in numbers]
print(squares) # [1, 4, 9, 16, 25]
# With a filter
even_squares = [n * n for n in numbers if n % 2 == 0]
print(even_squares) # [4, 16]
Pattern 5 — Loop With break, continue, and else
# ─── break — exit the loop early ────────────────────────
numbers = [1, 3, 5, 8, 10, 12]
for n in numbers:
if n % 2 == 0:
print(f"First even: {n}")
break
# First even: 8
# ─── continue — skip current, keep looping ──────────────
for n in numbers:
if n % 2 != 0:
continue # skip odd numbers
print(n) # 8, 10, 12
# ─── for-else — runs ONLY if loop finished without break ─
for n in numbers:
if n == 99:
print("Found 99!")
break
else:
print("99 not found in the list") # this runs
Removing or inserting items into a list while you loop over it causes silent skips, IndexErrors, or infinite loops. If you need to filter, build a new list with a comprehension: nums = [n for n in nums if n > 0]. Or iterate over a copy: for n in nums[:]:.
Nested Lists — Lists of Lists
Because a list can hold any type, one list can hold other lists. This is how you build 2D grids, matrices, tables, and tree-like structures in Python.
# ─── A 3x3 GRID ─────────────────────────────────────────
grid = [
[1, 2, 3],
[4, 5, 6],
[7, 8, 9]
]
# Access with double indexing: grid[row][col]
print(grid[0][0]) # 1 — top-left
print(grid[2][2]) # 9 — bottom-right
print(grid[1][2]) # 6 — middle row, last col
# Modify one cell
grid[1][1] = 0
print(grid) # [[1, 2, 3], [4, 0, 6], [7, 8, 9]]
# ─── LOOP OVER A 2D GRID ────────────────────────────────
for row in grid:
for cell in row:
print(cell, end=" ")
print()
# OUTPUT:
# 1 2 3
# 4 0 6
# 7 8 9
# ─── WITH ROW/COL INDICES ───────────────────────────────
for r, row in enumerate(grid):
for c, cell in enumerate(row):
if cell == 0:
print(f"Zero at ({r},{c})")
# ─── BUILD A 5x5 MULTIPLICATION TABLE ───────────────────
table = [[i * j for j in range(1, 6)] for i in range(1, 6)]
for row in table:
print(row)
# OUTPUT:
# [1, 2, 3, 4, 5]
# [2, 4, 6, 8, 10]
# [3, 6, 9, 12, 15]
# [4, 8, 12, 16, 20]
# [5, 10, 15, 20, 25]
Complete Practice Program — Inventory Manager
# inventory.py — every list concept in one working program
def main():
# ─── CREATE — literal syntax ────────────────────────
inventory = ["apple", "bread", "milk", "eggs", "cheese"]
prices = [1.20, 3.50, 2.80, 4.00, 5.50]
quantity = [12, 5, 8, 24, 3]
line = "─" * 50
print(f"{line}\n INVENTORY REPORT\n{line}")
# ─── LOOP WITH zip() — parallel iteration ───────────
print(f" {'ITEM':<10}{'QTY':>6}{'PRICE':>10}{'TOTAL':>12}")
grand_total = 0
for item, qty, price in zip(inventory, quantity, prices):
total = qty * price
grand_total += total
print(f" {item:<10}{qty:>6}${price:>9.2f}${total:>11.2f}")
print(f"{line}\n Grand total: ${grand_total:.2f}")
# ─── QUERIES — len, min, max, sum ──────────────────
print(f"\n Items in stock: {len(inventory)}")
print(f" Cheapest item: ${min(prices):.2f}")
print(f" Priciest item: ${max(prices):.2f}")
print(f" Total units: {sum(quantity)}")
# ─── INDEXING & SLICING ────────────────────────────
print(f"\n First item: {inventory[0]}")
print(f" Last item: {inventory[-1]}")
print(f" Top 3: {inventory[:3]}")
print(f" Reversed: {inventory[::-1]}")
# ─── MUTATORS — append, insert, remove, sort ────────
inventory.append("yogurt")
inventory.insert(0, "almonds")
inventory.remove("eggs")
inventory.sort()
print(f"\n Updated (sorted): {inventory}")
# ─── LIST COMPREHENSION — build from existing ──────
on_sale = [item for item, price in zip(inventory, prices) if price < 3]
print(f" Items under $3: {on_sale}")
# ─── FOR-ELSE — search pattern ─────────────────────
target = "chocolate"
for item in inventory:
if item == target:
print(f" Found {target}!")
break
else:
print(f" {target} is out of stock")
if __name__ == "__main__":
main()