Lists — Creation, Indexing, Methods
The workhorse data structure of Python — creating and slicing lists, every common method, mutability in depth, the shallow-copy trap, and nested lists.
Lists — An Ordered, Mutable Collection of Anything
A list is Python's general-purpose ordered collection — it can hold any number of items, in any type, including a mix of types in the same list, and it remembers the order items were added in. You have already seen lists used casually in earlier modules; this module is where you learn every operation you will actually use on them, in depth.
empty = []
numbers = [1, 2, 3, 4, 5]
names = ["Maria", "Jordan", "Priya"]
mixed = [1, "two", 3.0, True, None] # a list can freely mix types
# list() can also build a list from any iterable
letters = list("Python") # ['P', 'y', 't', 'h', 'o', 'n']
evens = list(range(0, 10, 2)) # [0, 2, 4, 6, 8]Indexing — accessing a single item by position
List indexing works exactly like the string indexing you learned in the Strings module — zero-based, with negative indices counting from the end. This is not a coincidence: strings and lists are both examples of Python's sequence types, and sequences share a common indexing and slicing interface.
fruits = ["apple", "banana", "cherry", "date"]
print(fruits[0]) # apple — first item
print(fruits[-1]) # date — last item
print(fruits[2]) # cherry — third item
fruits[1] = "blueberry" # lists are mutable — items can be reassigned by index
print(fruits) # ['apple', 'blueberry', 'cherry', 'date']my_string[0] = "X" raises a TypeError. Lists are mutable — fruits[1] = "blueberry" works, changing the list in place. This single difference explains almost everything that feels different about working with lists versus strings, and it is the subject of Part 04 below.Slicing — extracting a sub-list
numbers = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
print(numbers[2:5]) # [2, 3, 4] — indices 2, 3, 4 (5 excluded)
print(numbers[:3]) # [0, 1, 2] — from the start up to (not including) 3
print(numbers[7:]) # [7, 8, 9] — from 7 to the end
print(numbers[::2]) # [0, 2, 4, 6, 8] — every second item
print(numbers[::-1]) # [9, 8, 7, 6, 5, 4, 3, 2, 1, 0] — the whole list, reversed
# Unlike indexing a single out-of-range item, slicing never raises an error
print(numbers[5:100]) # [5, 6, 7, 8, 9] — just stops at the real end, no IndexErrorappend, insert, extend, remove, pop, and clear
Because lists are mutable, they come with a rich set of methods for changing their contents in place — adding items, removing them, and rearranging them, without creating a brand new list each time.
fruits = ["apple", "banana"]
fruits.append("cherry") # adds ONE item to the end
print(fruits) # ['apple', 'banana', 'cherry']
fruits.insert(1, "apricot") # inserts at a specific index, shifting the rest right
print(fruits) # ['apple', 'apricot', 'banana', 'cherry']
fruits.extend(["date", "fig"]) # adds MULTIPLE items, one at a time, from another iterable
print(fruits) # ['apple', 'apricot', 'banana', 'cherry', 'date', 'fig']fruits.append(["date", "fig"]) does not add two items — it adds one item, which is itself a list, producing [..., ['date', 'fig']], a nested list where you probably wanted a flat one. Use append() to add exactly one item; use extend() when the argument is itself a collection whose items should be added individually.fruits = ["apple", "banana", "cherry", "banana"]
fruits.remove("banana") # removes the FIRST matching value — not by index
print(fruits) # ['apple', 'cherry', 'banana']
last = fruits.pop() # removes AND RETURNS the last item by default
print(last) # banana
print(fruits) # ['apple', 'cherry']
first = fruits.pop(0) # pop() also accepts an explicit index
print(first) # apple
fruits.clear() # removes everything
print(fruits) # []remove(value) deletes by value and raises a ValueError if the value is not present. pop(index) deletes by position and hands the removed item back — useful when you need to do something with the item you just removed, like implementing a stack. Python's del statement (del fruits[0]) also deletes by index but, unlike pop(), does not return the removed value.sort() vs sorted(), reverse(), index(), and count()
Sorting is where the biggest, most consequential-to-get-wrong distinction in this entire module lives: .sort() and sorted() look similar and do related things, but behave completely differently, and mixing them up causes real bugs.
numbers = [4, 1, 3, 2]
result = numbers.sort()
print(numbers) # [1, 2, 3, 4] — the ORIGINAL list was changed
print(result) # None — .sort() does NOT return the sorted list!numbers = [4, 1, 3, 2]
result = sorted(numbers)
print(numbers) # [4, 1, 3, 2] — UNCHANGED
print(result) # [1, 2, 3, 4] — a brand new, separate listnumbers = numbers.sort(). This looks reasonable — "sort the list and store the result" — but since .sort() returns None, this line silently replaces numbers with None, destroying the data entirely. Rule to memorise: use .sort() as its own statement when you want to sort in place; use sorted(list_name) as an expression when you need a new sorted list without disturbing the original.words = ["banana", "kiwi", "apple", "fig"]
sorted(words, key=len) # ['kiwi', 'fig', 'apple', 'banana'] — sorted by length
sorted(words, reverse=True) # ['kiwi', 'fig', 'banana', 'apple'] — Z to A
sorted(words, key=len, reverse=True) # combined — longest firstnumbers = [3, 1, 4, 1, 5, 9, 2, 6]
numbers.reverse() # reverses the list IN PLACE (not alphabetical/numeric — just order)
print(numbers) # [6, 2, 9, 5, 1, 4, 1, 3]
print(numbers.index(4)) # 4 — the INDEX of the first "4" found
print(numbers.count(1)) # 2 — how many times "1" appears in the list
# index() raises ValueError if the value isn't present at all — check with "in" first if unsure
if 100 in numbers:
print(numbers.index(100))
else:
print("100 is not in the list")Mutability — The Payoff of Understanding It Properly Now
The Variables & Data Types module introduced mutability as an abstract distinction between types. Lists are where that distinction stops being abstract and starts being something you need to actively manage, every single day you write Python.
original = [1, 2, 3]
reference = original # NOT a copy — "reference" points at the SAME list object
reference.append(4)
print(original) # [1, 2, 3, 4] — changed too!
print(reference) # [1, 2, 3, 4]
print(original is reference) # True — literally the same object in memoryThis is not a special quirk of lists specifically — it is exactly the same name-binding behaviour from the Variables module, just now with consequences that are easy to trip over in real code. reference = original never copies anything. It creates a second name pointing at the exact same list object. Any mutation performed through either name is visible through both, because there is, underneath, only ever one list.
Where this bites in real functions
This becomes especially easy to miss when a list is passed into a function. Python passes objects by reference — a function that mutates a list parameter is mutating the caller's original list, not a private copy, because no copy was ever made.
def add_bonus_item(cart):
cart.append("free_sample") # mutates the list the CALLER passed in
return cart
my_cart = ["shirt", "pants"]
result = add_bonus_item(my_cart)
print(my_cart) # ['shirt', 'pants', 'free_sample'] — the original changed too, even
# though we only assigned the function's return value to "result"
print(result) # ['shirt', 'pants', 'free_sample']
print(my_cart is result) # True — same list, two nameslist1 = list2 vs .copy() vs [:] — Three Very Different Things
Given how much damage sharing a list by accident can do, it is worth being completely precise about the three ways you will see a "copy" written in real code — only two of them actually copy anything.
original = [1, 2, 3]
alias = original # NOT a copy — same object, two names
copy1 = original.copy() # a REAL copy — a new list with the same items
copy2 = original[:] # ALSO a real copy — slicing the whole list
original.append(99)
print(original) # [1, 2, 3, 99]
print(alias) # [1, 2, 3, 99] — changed, because it's the SAME object
print(copy1) # [1, 2, 3] — unaffected, genuinely independent
print(copy2) # [1, 2, 3] — also unaffected.copy() and the full-list slice [:] both produce a genuinely new, independent list object. Either is fine and idiomatic — .copy() is generally considered slightly more readable since it says what it does directly, while [:] is older and still extremely common in real code you will read.
Nested Lists — Why a Shallow Copy Isn't Always Enough
A list can contain other lists as items — commonly used to represent a grid, a matrix, or rows of tabular data. This is where the shallow-copy limitation from Part 05 becomes a real, visible bug rather than a theoretical footnote.
matrix = [[1, 2], [3, 4]]
shallow = matrix.copy() # a real copy of the OUTER list...
shallow[0].append(99) # ...but the INNER lists are still shared!
print(matrix) # [[1, 2, 99], [3, 4]] — changed, even though we only touched "shallow"!
print(shallow) # [[1, 2, 99], [3, 4]]
print(matrix is shallow) # False — the outer lists are genuinely different objects
print(matrix[0] is shallow[0]) # True — but the INNER lists are still the same object.copy() duplicated the outer list — a new list object was created to hold the references — but it copied those references, not the objects they point to. Both matrix[0] and shallow[0] point at the exact same inner list, so mutating one through either name is visible through both, for exactly the same reason two names pointing at the same list share mutations in Part 04.
import copy
matrix = [[1, 2], [3, 4]]
deep = copy.deepcopy(matrix) # recursively copies EVERY nested object, not just the outer list
deep[0].append(99)
print(matrix) # [[1, 2], [3, 4]] — genuinely unaffected
print(deep) # [[1, 2, 99], [3, 4]] — only the deep copy changed.copy() or [:] whenever a list's items are all immutable (numbers, strings, tuples) — there is nothing a shallow copy can miss in that case, since there is nothing nested to share. Reach for copy.deepcopy() specifically when a list contains other mutable objects (lists, dicts) that also need to be genuinely independent after copying. You will meet this exact problem again, in a slightly different shape, once you reach the Nested Data Structures module (Module 13).== vs is for Lists — Equal Contents Are Not the Same Object
This closes the loop on everything above with the same == vs is distinction from the Variables module, now made completely concrete with lists.
list_a = [1, 2, 3]
list_b = [1, 2, 3]
list_c = list_a
print(list_a == list_b) # True — equal CONTENTS, compared item by item
print(list_a is list_b) # False — two separate objects that happen to hold equal values
print(list_a == list_c) # True — also equal contents
print(list_a is list_c) # True — AND the same object, since list_c = list_a shares it== on two lists compares their contents, element by element — it answers "do these look the same?" is compares identity — it answers "are these literally the same object in memory?" Two independently created lists with identical contents will always be == but never is. The practical rule is unchanged from the Variables module: use == for essentially all comparisons; reserve is for checking against None or for deliberately confirming two names refer to one shared object — exactly the check used throughout this module to explain the copying behaviour above.
A Minneapolis Grocery-Delivery App Overwrites Every Driver's Route With One Driver's Route
A Minneapolis grocery-delivery startup assigns each driver a base route template — a list of standard stops for their zone — which the dispatch system then customises per driver by appending that day's specific delivery stops. One morning, every driver on the app opens their route and sees the exact same twenty-two stops, none of which match their own zone. Dispatch is flooded with confused calls within minutes of the shift starting.
What the engineer finds
The dispatch code builds each driver's route by starting from a shared base_route template and appending stops directly onto it — exactly the Part 04 problem, list assignment creating a shared reference rather than an independent copy.
base_route = ["Warehouse A", "Warehouse B"] # shared starting template
def build_driver_route(driver, todays_stops):
route = base_route # NOT a copy — "route" points at the shared base_route
route.extend(todays_stops) # mutates base_route itself, for EVERY driver
driver.route = route
return route
for driver in get_active_drivers():
build_driver_route(driver, get_stops_for(driver))Every call to build_driver_route appends that driver's stops onto the same shared base_route list, since route = base_route never copied anything. By the time the last driver's route was built, base_route contained every single stop from every driver processed so far — and because every driver object's .route attribute pointed at that same growing list, all of them ended up looking identical, and identical to whichever driver was processed last.
The fix
base_route = ["Warehouse A", "Warehouse B"]
def build_driver_route(driver, todays_stops):
route = base_route.copy() # a genuinely independent list per driver
route.extend(todays_stops)
driver.route = route
return routeOne added method call — .copy() — fixes the entire incident. The lesson the team takes away, and the one worth internalising from this module generally: any time a "starting point" list is going to be built on by multiple independent callers, ask explicitly whether each caller needs its own copy, because Python will never make one for you silently.
Four Misconceptions About Lists
5 Interview Questions — With Complete Answers
List Mistakes Beginners Make Constantly
Errors You Will Hit With Lists — And Exactly Why
🎯 Key Takeaways
- ✓Lists are ordered, mutable, and can hold any mix of types. Indexing and slicing work like strings — zero-based, with negative indices from the end — but items can be reassigned in place.
- ✓append() adds exactly one item (even a whole list, nested); extend() adds each item of another iterable individually, keeping the result flat.
- ✓sort() sorts in place and returns None; sorted() returns a new sorted list, leaving the original untouched. Never write my_list = my_list.sort().
- ✓new_name = old_list never copies — both names point at the same object. Real copies require .copy(), the full slice [:], or copy.deepcopy() for nested structures.
- ✓.copy() and [:] are shallow copies — nested mutable objects (like inner lists) are still shared between the original and the copy. Use copy.deepcopy() when full independence is required.
- ✓== compares list contents element by element; is checks whether two names refer to the literal same object in memory.
- ✓Mutating a list inside a function mutates the caller's original list too, since no copy is made when the list is passed in — the same mechanism behind the mutable-default-argument trap.
- ✓remove() deletes by value; pop() deletes by index and returns the removed item; del deletes by index without returning anything.
What comes next
Module 09 covers tuples and sets — immutable sequences, unpacking, hashability, and the set operations that make membership checks dramatically faster than scanning a list.
Module 09 → Tuples and SetsDiscussion
0Have a better approach? Found something outdated? Share it — your knowledge helps everyone learning here.