Python Lists — The Complete Guide
Creation, slicing, mutability, methods, comprehensions, and copying — explained the way you'd explain it to a teammate at 2am, not the way a textbook does.
Last Updated
July 2026
Read Time
33 min
Level
Beginner to Intermediate
What Is a List in Python?
A list in Python is an ordered, mutable collection that can hold any mix of object types — numbers, strings, other lists, custom objects, even functions — all in the same list at once. That's the textbook definition. Here's the version that actually matters once you start writing real code: mutable is the operative word, and it's the single biggest thing separating lists from the strings and tuples covered elsewhere in this series. Where a string operation always hands you back a brand-new object, a list method very often changes the existing object in place, and every other variable pointing at that same list sees the change too. That distinction is the source of both list's biggest strengths and its most common bugs.
Lists are written with square brackets, comma-separated: [1, 2, 3]. They're ordered, meaning the position of each element is meaningful and preserved — the second item you add stays the second item until something explicitly changes that. They allow duplicates, they allow mixed types, and they can be nested arbitrarily deep, which is exactly how you represent a 2D grid, a JSON-style tree structure, or a simple matrix without reaching for a specialized library.
Here's my honestly opinionated take, upfront, so you can disagree with me for the rest of the article: the list concepts most tutorials rush through — aliasing versus copying, the mutable default argument trap, and the real performance difference between list operations at the start versus the end — are exactly the ones that cause production bugs. Appending an item and indexing into a list, everyone gets right on day one. It's the quiet 'this list is shared somewhere I didn't expect' bugs that eventually bite you during a code review, or worse, during an on-call incident where a cache silently corrupted itself. We're going to spend disproportionate time on those.
Creating Lists — Literals, list(), and Comprehensions
There are several distinct ways to build a list in Python, and picking the right one for the situation is a small but real readability decision. The square-bracket literal is by far the most common for a fixed, known set of values. The list() constructor is what you reach for when converting some other iterable — a tuple, a string, a range, a generator, the keys of a dictionary — into a genuine list.
fruits = ['apple', 'banana', 'mango']
mixed = [1, 'two', 3.0, [4, 5], True]
empty = []
from_range = list(range(5)) # [0, 1, 2, 3, 4]
from_string = list('abc') # ['a', 'b', 'c']
from_tuple = list((10, 20, 30)) # [10, 20, 30]
from_dict_keys = list({'a': 1, 'b': 2}) # ['a', 'b'] — dict keys, in insertion orderThe [x] * n Repetition Trap With Nested Lists
This is one of the most common early-career bugs, and it directly connects to the aliasing theme we'll return to throughout this guide. [0] * 5 works exactly as expected because integers are immutable — each of the five positions just holds its own independent reference to the same immutable 0 object, and that's fine because you can never mutate 0 in place anyway. But [[]] * 3, meant to create three independent empty lists, actually creates three references to the exact same single inner list. Mutating one 'copy' mutates all of them, because there was only ever one inner list object to begin with.
# This works fine — integers are immutable, no shared mutable state
row = [0] * 5
print(row) # [0, 0, 0, 0, 0]
# This is the trap — all 3 rows are the SAME list object
grid = [[]] * 3
grid[0].append('X')
print(grid) # [['X'], ['X'], ['X']] <- all three changed!
# Correct way to build a grid of independent inner lists
grid = [[] for _ in range(3)]
grid[0].append('X')
print(grid) # [['X'], [], []] <- only the intended row changedOutput
[0, 0, 0, 0, 0] [['X'], ['X'], ['X']] [['X'], [], []]I've seen this exact bug in a Tic-Tac-Toe implementation, a Sudoku solver, and a genuine production feature that tracked per-region inventory counts, all written by developers who understood lists perfectly well in isolation but hadn't internalized that * repeats references, not independent copies, when the element itself is mutable.
Indexing and Slicing Lists
Lists support the same indexing and slicing syntax as strings, since both are sequence types under the hood, but with one crucial difference: list slices and indices can be assigned to, because lists are mutable. This single capability — assignable slices — is genuinely powerful and lets you replace, insert, or delete a whole chunk of a list in one line, something strings can never do.
nums = [10, 20, 30, 40, 50]
print(nums[0]) # 10
print(nums[-1]) # 50
print(nums[1:3]) # [20, 30]
print(nums[::-1]) # [50, 40, 30, 20, 10] — reversed copy
# Slice assignment — lists can do this, strings never can
nums[1:3] = [200, 300, 350] # replaces 2 elements with 3 — list grows
print(nums) # [10, 200, 300, 350, 40, 50]
nums[0:2] = [] # deletes a slice by assigning empty list
print(nums) # [300, 350, 40, 50]Output
10 50 [20, 30] [50, 40, 30, 20, 10] [10, 200, 300, 350, 40, 50] [300, 350, 40, 50]Slicing Always Returns a Shallow Copy
Any slice of a list — even nums[:], which takes the whole thing — creates a genuinely new list object, distinct from the original. This is the fastest, most idiomatic way to make a quick shallow copy of a flat list. The word 'shallow' matters and is covered in depth later in this guide: the new outer list is independent, but if the original list contains nested mutable objects like other lists or dicts, those inner objects are still shared between the original and the copy.
original = [1, 2, 3]
copy = original[:]
copy.append(4)
print(original) # [1, 2, 3] — unaffected, copy is a real independent list
print(copy) # [1, 2, 3, 4]
print(original is copy) # False — genuinely different objectsMutability — Why Lists Behave So Differently From Strings
Every method that changes a list's contents — append(), extend(), insert(), remove(), pop(), sort(), reverse(), clear() — modifies the existing object in place and returns None. This is a deliberate, consistent design choice across the entire mutable-methods family, and it's exactly the reason my_list = my_list.append(5) is one of the most common beginner mistakes: append() already mutated my_list in place, and its return value is always None, so that line silently overwrites your actual list with None.
cart = ['apple', 'bread']
# WRONG — append() mutates in place and returns None
cart = cart.append('milk')
print(cart) # None <- cart is now None, not the updated list!
# CORRECT — just call the method, don't reassign
cart = ['apple', 'bread']
cart.append('milk')
print(cart) # ['apple', 'bread', 'milk']Output
None ['apple', 'bread', 'milk']Aliasing — Two Names, One List
When you assign one list variable to another with plain =, you are not copying the list — you're creating a second name that points at the exact same object in memory. This is called aliasing, and it's true for every mutable object in Python, not just lists. Mutating the list through either name affects what both names see, because there was only ever one list to begin with.
cart_a = ['apple', 'bread']
cart_b = cart_a # NOT a copy — cart_b is another name for the SAME list
cart_b.append('milk')
print(cart_a) # ['apple', 'bread', 'milk'] — cart_a changed too!
print(cart_a is cart_b) # True — literally the same objectThis is genuinely one of the top three sources of hard-to-track bugs for anyone learning Python, and it doesn't stop being relevant once you're experienced — it just shows up in subtler forms, like a function that receives a list as an argument, mutates it internally 'just to sort it before processing,' and unintentionally sorts the caller's original list too, because Python passes object references, not copies, into function calls.
def process_scores(scores):
scores.sort() # mutates the CALLER'S list, not a local copy
return scores[0]
user_scores = [88, 45, 92, 67]
lowest = process_scores(user_scores)
print(lowest) # 45
print(user_scores) # [45, 67, 88, 92] — caller's list got sorted as a side effect!
# Safer version — copy before mutating, or use sorted() instead of sort()
def process_scores_safe(scores):
ordered = sorted(scores) # sorted() returns a NEW list, doesn't touch the original
return ordered[0]List Methods — The Ones You'll Actually Use
Lists ship with a focused set of built-in methods for adding, removing, searching, and reordering elements. Rather than listing all of them mechanically, here's the set that actually shows up in day-to-day production code, grouped by what they're for, along with the specific gotchas attached to each.
Adding Elements — append(), extend(), insert()
These three are easy to mix up, and mixing them up produces genuinely different, sometimes surprising results rather than an error, which makes the bug harder to spot. append() always adds exactly one item to the end, no matter what that item is — even if it's itself a list, it gets added as a single nested element, not merged in. extend() takes an iterable and adds each of its elements individually to the end. insert() adds one item at a specific position, shifting everything after it to the right.
nums = [1, 2, 3]
nums.append([4, 5])
print(nums) # [1, 2, 3, [4, 5]] — added as ONE nested list item
nums = [1, 2, 3]
nums.extend([4, 5])
print(nums) # [1, 2, 3, 4, 5] — each element added individually
nums = [1, 2, 3]
nums.insert(1, 'X')
print(nums) # [1, 'X', 2, 3] — inserted at index 1, rest shifted rightOutput
[1, 2, 3, [4, 5]] [1, 2, 3, 4, 5] [1, 'X', 2, 3]The append([4, 5]) versus extend([4, 5]) confusion is a genuinely common source of a bug that produces a nested-list-shaped result nobody was expecting, especially when combining results across a loop, where the intention was almost always to flatten the results together, not nest them.
Removing Elements — remove(), pop(), del, clear()
Four different tools exist for removing elements, and each answers a slightly different question. remove(value) deletes the first occurrence of a given value, and raises ValueError if the value isn't present. pop(index) deletes and returns the element at a given index (defaulting to the last element if no index is given), which makes it the natural choice for implementing a stack. The del statement deletes by index or slice without returning anything. clear() empties the entire list in place.
queue = ['a', 'b', 'c', 'b']
queue.remove('b') # removes the FIRST 'b' only
print(queue) # ['a', 'c', 'b']
last = queue.pop() # removes and returns the last item
print(last, queue) # 'b' ['a', 'c']
first = queue.pop(0) # removes and returns the item at index 0
print(first, queue) # 'a' ['c']
del queue[0] # deletes by index, returns nothing
print(queue) # []Sorting — sort() vs sorted()
This is probably the single most important method pair to get right, because they look almost identical in name and purpose but behave fundamentally differently. list.sort() is a method that sorts the list in place and returns None. sorted() is a built-in function that works on any iterable, not just lists, and always returns a brand new sorted list, leaving the original completely untouched.
nums = [4, 1, 3, 2]
result = nums.sort()
print(result) # None — sort() always returns None
print(nums) # [1, 2, 3, 4] — original was mutated in place
nums = [4, 1, 3, 2]
new_list = sorted(nums)
print(new_list) # [1, 2, 3, 4] — new, independent sorted list
print(nums) # [4, 1, 3, 2] — original untouched
# Both support key and reverse
words = ['banana', 'fig', 'apple']
print(sorted(words, key=len)) # ['fig', 'apple', 'banana']
print(sorted(words, reverse=True)) # ['fig', 'banana', 'apple']The rule that actually sticks: reach for sorted() by default, especially inside functions, because it never has the side effect of mutating something the caller didn't expect to change. Only use list.sort() when you deliberately want the in-place mutation and you're confident nothing else holds a reference to that same list that needs the original order preserved.
List Comprehensions — Building Lists Declaratively
A list comprehension is a compact, single-line way to build a new list by applying an expression to every item of an iterable, optionally filtering which items are included. It's arguably the most distinctly 'Pythonic' piece of syntax in the entire language, and once it clicks, writing an equivalent manual for loop starts to feel unnecessarily verbose for the simple cases it covers.
# The manual loop version
squares = []
for n in range(10):
squares.append(n ** 2)
# The equivalent comprehension — same result, one line
squares = [n ** 2 for n in range(10)]
print(squares) # [0, 1, 4, 9, 16, 25, 36, 49, 64, 81]
# With a filter condition
evens = [n for n in range(20) if n % 2 == 0]
print(evens) # [0, 2, 4, 6, 8, 10, 12, 14, 16, 18]
# With a transform AND a filter together
even_squares = [n ** 2 for n in range(10) if n % 2 == 0]
print(even_squares) # [0, 4, 16, 36, 64]Nested Comprehensions — Flattening a 2D List
Comprehensions can contain more than one for clause, which is genuinely useful for flattening nested structures in a single expression, but it's also exactly the point where readability starts to suffer if you push it too far. The rule of thumb worth following: one level of nesting inside a comprehension is fine and common; two or more levels, or a comprehension containing complex conditional logic, is usually clearer written as a normal multi-line loop instead.
matrix = [[1, 2, 3], [4, 5, 6], [7, 8, 9]]
flat = [num for row in matrix for num in row]
print(flat) # [1, 2, 3, 4, 5, 6, 7, 8, 9]
# Read it left to right, exactly like the equivalent nested loop:
flat = []
for row in matrix:
for num in row:
flat.append(num)When NOT to Use a Comprehension
Comprehensions are for building a new list from a source iterable — that's their entire job. Using one purely for its side effects (like calling a function on every item without caring about the return values) is a genuine anti-pattern, because it builds and immediately discards a throwaway list just to trigger a loop, which is wasteful and confusing to read. If you don't need the resulting list, write a plain for loop.
# Misuse — building a list purely to trigger print() as a side effect
[print(item) for item in ['a', 'b', 'c']] # works, but wasteful and unclear
# Correct — a plain loop, since there's no list you actually need
for item in ['a', 'b', 'c']:
print(item)Copying Lists — Shallow vs Deep Copy
This is where the aliasing discussion from earlier gets a proper, complete resolution. Python gives you three genuinely different ways to duplicate a list, and they behave identically for a flat list of immutable values but diverge sharply the moment the list contains nested mutable objects like other lists or dictionaries.
import copy
original = [[1, 2], [3, 4]]
shallow = original.copy() # or original[:] or list(original)
shallow[0].append(99) # mutates the INNER list — shared with original!
print(original) # [[1, 2, 99], [3, 4]] — original changed too
original = [[1, 2], [3, 4]]
deep = copy.deepcopy(original)
deep[0].append(99) # mutates a genuinely independent inner list
print(original) # [[1, 2], [3, 4]] — completely unaffectedOutput
[[1, 2, 99], [3, 4]] [[1, 2], [3, 4]]The practical decision rule: if you're absolutely certain your list only holds immutable elements (numbers, strings, tuples of immutables), a shallow copy via .copy() is fast and completely sufficient. The moment your list contains other lists, dicts, sets, or custom mutable objects, and you actually need full independence between the copy and the original, reach for copy.deepcopy() — it's slower, since it has to walk the entire nested structure, but it's the only option that guarantees no shared mutable state anywhere in the structure.
How Python Handles a List Assignment or Copy — Visual Flow
Because aliasing versus copying is genuinely the single most consequential decision you make any time you hand a list to another variable or a function, it helps to see the actual decision path laid out visually. The diagram below traces what happens for each of the common ways to 'copy' a list, and what that means for shared mutable state afterward.
The branch worth staring at longest is the middle one — shallow copy with nested mutable content. It's the exact spot where developers who correctly understand plain aliasing still get caught, because .copy() genuinely does create a new outer list, which feels like it should be 'safe,' right up until you mutate something one level deeper and discover the original changed anyway.
The Mutable Default Argument Trap
This is, without exaggeration, one of the most infamous gotchas in the entire language, and it directly follows from everything covered so far about mutability. Default argument values in a Python function definition are evaluated exactly once, at the moment the function is defined — not once per call. If that default value is a mutable object like a list, every call that doesn't explicitly provide its own argument ends up sharing and mutating that exact same default list, across every single invocation of the function, for the entire lifetime of the program.
def add_item(item, cart=[]): # DANGER: [] is created ONCE, at definition time
cart.append(item)
return cart
print(add_item('apple')) # ['apple']
print(add_item('bread')) # ['apple', 'bread'] <- 'apple' is still there!
print(add_item('milk')) # ['apple', 'bread', 'milk'] <- keeps growingOutput
['apple'] ['apple', 'bread'] ['apple', 'bread', 'milk']Each call was clearly meant to start with a fresh, empty cart, but because the default [] is a single object created once and reused across every call that omits the argument, the cart quietly accumulates items across unrelated calls. This exact bug has shipped in real production code more times than anyone likes to admit, usually surfacing as 'unrelated requests are somehow seeing each other's data,' which is a genuinely alarming class of bug to debug from the outside.
The Standard Fix — Default to None, Create Inside the Function
def add_item(item, cart=None):
if cart is None:
cart = [] # a NEW list, created fresh on every call
cart.append(item)
return cart
print(add_item('apple')) # ['apple']
print(add_item('bread')) # ['bread'] <- correctly independent now
print(add_item('milk')) # ['milk']This =None then if x is None: x = [] pattern is such a well-known, universally recognized fix that it's basically a Python idiom in its own right. Any linter worth using (pylint, ruff, flake8 with the right plugin) will flag a mutable default argument automatically, which is exactly the kind of automated safety net worth having turned on in every project.
Lists vs Tuples — When to Choose Which
Lists and tuples look deceptively similar — both are ordered sequences that can hold mixed types — and beginners often treat the choice between them as arbitrary syntax preference. It isn't. The core distinction is exactly the mutability theme running through this entire guide, and it has real, practical consequences beyond just 'square brackets versus parentheses.'
A genuinely useful mental shortcut: reach for a tuple any time you're representing a fixed-shape record, like coordinates (x, y), an RGB color (255, 0, 0), or a database row with a known fixed set of fields — situations where the number and meaning of the elements never changes. Reach for a list any time the collection's size or contents are expected to change over the program's lifetime — appending results as they come in, building up a queue, filtering items down. Choosing correctly isn't just stylistic; it communicates intent to the next reader of your code, and it unlocks tuple-specific advantages like using it directly as a dictionary key, which a list can never do because lists aren't hashable.
Python Lists vs Java and JavaScript
If you move between languages regularly, this table saves you the 'wait, why doesn't .push() work' moment during a live coding round.
Where Each List Concept Actually Shows Up in Real Codebases
Lists aren't just interview fodder — here's where you'll genuinely run into each concept once you're working on real projects, not just tutorial exercises.
- ▶
Comprehensions in data transformation pipelines — [row.strip().split(',') for row in lines] is the standard first pass on almost any raw text-processing task before reaching for pandas.
- ▶
Deep copies in config and state management — snapshotting a nested settings dict-of-lists before letting a form or wizard mutate a working copy, so a 'cancel' action can safely revert.
- ▶
The None-default fix in every API and library function — any function signature accepting an optional list argument in a shared codebase should be using def f(items=None): as a defensive default.
- ▶
sorted() with a key function for custom ordering — sorted(users, key=lambda u: u['last_login'], reverse=True) is the everyday pattern for ranking, leaderboards, and admin dashboards.
- ▶
pop() and append() for stack-based algorithms — DFS traversals, undo/redo history, and bracket-matching problems lean on a plain list as a stack via append()/pop().
- ▶
Slicing for pagination — results[offset:offset + page_size] is the standard, dependency-free way to page through an in-memory list of results.
- ▶
list vs tuple as a design signal in function returns — a function returning (status_code, message) as a tuple signals 'fixed shape, done changing,' while returning errors as a list signals 'zero or more items, may grow.'
Do's and Don'ts With Python Lists
A quick reference for the habits that separate code that works from code that works AND doesn't fall over during a code review or an on-call rotation.
Python Lists — Interview Questions
These come up constantly, whether it's a fresher round at a service company or a mid-level screen at a product startup. Know the reasoning behind the answer, not just the answer itself — interviewers almost always ask a follow-up 'why'.
Practice Questions — Test Your Understanding
Try to answer each one mentally before checking. If you get one wrong, that's exactly the kind of gap that turns into a real bug later — worth re-reading that section above.
1. What is the output of print([1, 2, 3] + [4, 5]) and does it mutate either list?
Easy2. What does a[1:1] = ['X'] do to a = [10, 20, 30]?
Medium3. Why does my_list.count(5) return 0 instead of raising an error if 5 isn't in the list?
Easy4. What's the difference between del my_list[0] and my_list.pop(0)?
Medium5. Why might def append_to(item, target=[]): be a bug even if the function's tests all pass?
Hard6. What does list(zip([1, 2, 3], ['a', 'b', 'c'])) return?
Medium7. If a = [1, 2, 3] and b = a.copy(), does b.append(4) affect a?
Easy8. Why is inserting at the beginning of a very large list (list.insert(0, x)) considered a performance anti-pattern?
HardConclusion — Lists Are Simple Until Mutability Sneaks Up on You
Lists are one of the very first data structures every Python tutorial introduces, and for good reason — they're intuitive, flexible, and genuinely useful for an enormous range of everyday tasks right from your first script. The real learning curve isn't about the surface-level syntax of appending, indexing, or slicing; it's about internalizing what mutability actually means in practice, once a list is shared across two variable names, passed into a function, nested inside another list, or handed to another team's code as a default argument value.
If there's one habit worth building from this entire guide, it's this: whenever you assign a list to a new variable, pass it into a function, or store it as a default argument, pause for a second and ask whether you actually want a shared reference or an independent copy — and if it's a copy, whether shallow is enough or the nested content demands a deep copy. That two-second pause is cheaper than the debugging session you'll otherwise have three sprints from now, chasing why two seemingly unrelated parts of your program are somehow seeing each other's changes.
Next, take these list concepts into dictionaries and nested data structures, where lists as values inside a dict — and the exact same aliasing and copying rules covered here — combine in ways that reveal exactly why understanding mutability matters in practice, not just in theory. That's where lists stop being syntax you memorized and start being tools you reach for deliberately, with a clear sense of the consequences.