Lists: Advanced
A list comprehension builds a new list from an existing iterable in a single expression: [expression for item in iterable]. Python runs the loop, evaluates the expression once per item, and collects each result into a fresh list, in source order. Code that used to start with an empty list and fill it in a loop with append becomes a line that states what the list contains.
Both lists are equal, and amounts_cents is untouched: the comprehension only reads its source. The table names the parts you will find in every comprehension.
| Part | In the example | What Python does with it |
|---|---|---|
| Expression | `cents / 100` | Evaluated once per item; its value becomes the next item of the new list |
| Loop variable | `cents` | Bound to each source item in turn, visible only inside the brackets |
| Iterable | `amounts_cents` | Read from start to end; a string, a `range` or a generator works too |
| Filter (optional) | `if cents > 0` | Checked before the expression; items that fail are skipped |
Filtering with a trailing if
An if clause after the for decides which items reach the expression. Items that fail it are skipped, so the result can be shorter than the source. This payments feed mixes charges with refunds, stored as negative cents, and zero-value test events. The report wants charges only, in dollars.
Transforming with if-else in front
To keep every item but compute it differently, put a conditional expression in the expression slot: a if condition else b. It's the conditional assignment you already write on a single line, placed before the for. Every source item produces an output, so the length never changes.
- [c for c in xs if c > 0]
- Decides whether an item appears
- Takes no else; adding one is a SyntaxError
- Result can be shorter than the source
- [c if c > 0 else 0 for c in xs]
- Decides what an item becomes
- The else is required
- Result always matches the source's length
Both can appear together: ["big" if c > 10000 else "small" for c in xs if c > 0] drops non-positive amounts, then labels the rest.
Several for clauses
A comprehension can hold more than one for. The clauses run in the order you would nest the loops, outer loop first, so reading them left to right as indented loops tells you what comes out. A common use turns a list of batches into a list of rows.
Reverse the clauses to [row for row in batch for batch in batches] and Python evaluates batch before any clause defines it: a NameError, or a silent wrong answer if an earlier batch variable is still around. A comprehension can also sit in the expression slot of another. [[r * c for c in range(3)] for r in range(2)] gives [[0, 0, 0], [0, 1, 2]], and every pass of the outer loop builds a separate inner list, a detail the next section depends on.
The loop variable stays inside
A for loop leaves its variable bound after it ends. A comprehension runs in its own scope, so its loop variable vanishes with it and never overwrites a name outside.
When a loop reads better
A comprehension exists to produce a list. [print(r) for r in rows] prints each row and also builds a throwaway list of None values, because print returns None. Write a for loop when the point is a side effect, or when the logic needs several conditions and transforms that would crowd a single line. When you only need to scan the values once, parentheses instead of brackets give a generator expression, as in any(c < 0 for c in amounts_cents), which stops at the first refund and builds no list.
- Use a comprehension when the goal is a new list
- Filter with a trailing if; transform with if-else up front
- Order for clauses the way you would nest the loops
- Don't call functions for their side effects inside a comprehension
- Don't put else after a trailing filter if
- Don't expect a comprehension's loop variable to exist afterwards
What does this program print?
When you write a comprehension that both filters and converts, fill its slots by asking 2 questions in order: which items should survive, and what each survivor should become. The first answer is the condition after the for, and the second is the expression before it. Only survivors reach the expression, so a division placed there never runs on an item the filter dropped.
A variable holds a reference to a list object stored elsewhere in memory. The statement b = a copies the reference, so afterwards both names reach the same object, and a change made through either name is visible through the other. You already know the tool that reveals this: is compares identity and returns True when 2 names point at a single object.
1original = [1, 2, 3]2alias = original3alias.append(4)4alias[0] = 995print("original:", original)6print("alias:", alias)7print("same object:", alias is original)
The append and the item assignment both went through alias, and original shows both, because there was only ever a single list. Python never copies a list unless you ask it to.
Mutating the object vs rebinding the name
Calling a method changes the object, and so does assigning to an item or running del on one, so every name for it sees the change. A plain name = ... assignment looks similar but only points that single name at a different object, and it leaves the old object and its other names alone.
| Statement | What it does | Visible through original? |
|---|---|---|
| `alias.append(4)` | Mutates the shared list | Yes |
| `alias[0] = 99` | Replaces a slot in the shared list | Yes |
| `del alias[0]` | Removes a slot from the shared list | Yes |
| `alias = [7, 8]` | Points `alias` at a new list | No |
| `del alias` | Deletes the name `alias` only | No |
Functions get the caller's list
clean is raw prints True: the helper returned the very list it was given. Whether that's a bug depends on whether the caller still needed the raw rows.
Shallow copies: a new outer list
a.copy() gives you a new list. a[:] and list(a) do the same: each creates a new outer list and fills it with the references the original holds. For a flat list of numbers or strings that is all the independence you need, because those items can only be replaced, never changed in place. Replacing a slot in the copy leaves the original's slot alone. Items that are themselves lists get shared between the copy and the original.
1config = [["us", "eu"], 30]2backup = config.copy()3backup[1] = 604backup[0].append("apac")5print("config:", config)6print("backup:", backup)7print("outer shared:", config is backup)8print("inner shared:", config[0] is backup[0])
backup[1] = 60 replaced a slot in the new outer list, so config[1] is still 30. backup[0].append("apac") followed the reference in slot 0 into the inner list both outer lists point at, so config shows "apac" too. The last 2 lines confirm it: separate outer lists, a shared inner list.
Multiplying a list of lists
Multiplying a list repeats its references. [0] * 3 repeats a reference to 0 3 times, which is harmless because a slot holding 0 can only be replaced. [[0] * 3] * 3 repeats a reference to the same inner list 3 times, so a change to any row shows in every row. A comprehension evaluates [0] * 3 anew for each row, so each row is its own list.
Default arguments are built once
Python evaluates a default value once, when def runs. Every call that omits the argument receives that same object, so a list default collects items across calls.
The fix defaults to None and builds the list inside the function, so each call that omits tags gets a new empty list.
- ▸A helper cleans its argument in place, and the raw batch the caller kept for auditing changes with it.
- ▸A grid of per-hour counters built with
*adds every count to every hour. - ▸A function with
rows=[]as a default keeps rows from the previous call. - ▸A shallow copy of a list of rows still shares each row, so editing a field in the copy edits the source.
Predict the output.
The None default fixes the shared list for a concrete reason. Every call that omits the argument still receives the same default object, but None is never mutated. The [] sits inside the function body, and the body runs on every call, so each call that reaches tags = [] builds a separate list.
A deep copy duplicates the whole structure. copy.deepcopy(x) from the standard copy module walks every list inside x, however deeply nested, and builds a new one for each, so nothing mutable is shared with the original. Numbers and strings are reused, which is safe because nobody can change them. The same module's copy.copy(x) makes a shallow copy, equivalent to x.copy() for a list.
1import copy23config = [["us", "eu"], 30]4shallow = copy.copy(config)5deep = copy.deepcopy(config)6config[0].append("apac")7print("shallow:", shallow)8print("deep:", deep)9print(config[0] is shallow[0], config[0] is deep[0])
Appending "apac" to the original's inner list reaches shallow, which shares that inner list, and misses deep, which got its own. The final line prints the identity checks: True for the shallow copy, False for the deep one.
| b = a | b = a.copy() | b = copy.deepcopy(a) | |
|---|---|---|---|
| New outer list? | No | Yes | Yes |
| New inner lists? | No | No | Yes, at every depth |
| `b.append(x)` changes `a`? | Yes | No | No |
| `b[0].append(x)` changes `a`? | Yes | Yes | No |
| Extra memory | None | The outer list | Every list inside |
Equal contents, separate objects
== compares lists item by item, descending into nested lists, so a deep copy is equal to its source. is compares identity, and a deep copy shares no list with its source at any level.
Shared references survive the copy
If the original holds the same inner list twice, deepcopy copies it once and puts that copy in both places. During the call it records every object it has already copied and reuses the copy when it meets the object again. The result keeps the source's pattern of sharing internally while sharing nothing with the source. The same record lets it copy a list that contains itself without looping forever.
Objects inside lists
deepcopy also copies instances of your own classes, attribute by attribute. A template job definition can be deep-copied into a run and the run's step list changed, with the template left as it was.
With copy.copy(template) instead, the new Job would share template.steps, and the insert would change both.
What a deep copy costs
A deep copy duplicates every container inside the structure. The time and memory it needs grow with the total number of nested lists and objects. On a batch of 200,000 rows, each a list of plain values, it builds 200,001 new lists. Some objects can't be copied at all: a generator or an open file raises a TypeError, because a half-consumed stream has no meaningful second copy.
When you know the shape, copy only the levels you will change. Rows stored as flat lists need 2 levels, and a comprehension handles them: [row[:] for row in rows]. It copies each row without inspecting the values inside, and it runs faster than deepcopy because it skips the per-object record keeping.
- The items are values you only replace, like numbers and strings
- You will change which items the list holds or their order, never edit an inner list
- Sharing the inner rows is intended because nobody edits them
- You'll edit inner lists or object attributes in the copy
- The nesting depth varies or is unknown
- A template must stay unchanged across many runs
What does this program print?
The copy functions live in a module, so a program needs import copy at the top and the module name in front of each call, as in copy.deepcopy(x). A bare deepcopy(x) raises NameError: name 'deepcopy' is not defined unless the program imported it by name with from copy import deepcopy.
A stack hands back items in the reverse of the order they arrived: the last item in is the first item out. A Python list does this with 2 methods that both work at the right-hand end. append(x) pushes x onto the top, and pop() with no argument removes the top item and returns it. Neither method moves the other items, so a push or a pop stays fast however tall the stack gets.
1stack = []2stack.append("first")3stack.append("second")4stack.append("third")5print("after pushes:", stack)67item = stack.pop()8print("popped:", item)9print("now:", stack)1011item = stack.pop()12print("popped:", item)13print("now:", stack)
| Stack operation | List code | On an empty stack |
|---|---|---|
| Push | `stack.append(x)` | Works |
| Pop | `stack.pop()` | IndexError: pop from empty list |
| Peek at the top | `stack[-1]` | IndexError: list index out of range |
| Check for empty | `not stack` | `True` |
| Size | `len(stack)` | `0` |
An empty list is falsy, so while stack: runs until the stack is drained and if stack: guards a pop. Without the guard, popping an empty stack raises an IndexError.
Undo is a stack
1history = []2text = ""34history.append(text)5text = "Hello"6print("after edit 1:", text)78history.append(text)9text = "Hello World"10print("after edit 2:", text)1112text = history.pop()13print("after undo:", text)14print("history:", history)
Matching brackets
Checking that brackets nest correctly in a config string or a SQL expression is a stack problem. Push each opening bracket. When a closing bracket arrives, the top of the stack must be its partner, and anything else means the brackets cross. At the end, an empty stack means every opener was closed. The PAIRS lookup maps each closer to its opener, the same dict-based dispatch you have written before.
1PAIRS = {")": "(", "]": "[", "}": "{"}23def is_balanced(s):4 stack = []5 for char in s:6 if char in "([{":7 stack.append(char)8 elif char in ")]}":9 if not stack or stack.pop() != PAIRS[char]:10 return False11 return len(stack) == 01213print(is_balanced("([)]"))
One call on ([)]: the ) arrives while [ is on top of the stack, so the check fails
On "([)]", the function pushes ( and then [. When ) arrives, [ is on top, so the function returns False at the third character without reading the last. The not stack test in the same condition catches a closer with nothing open, like "())". It short-circuits before pop() could raise.
Walking nested data without recursion
Why a list makes a poor queue
A queue is first-in first-out, and pop(0) gives that order from a list, but removing slot 0 shifts every remaining item one slot to the left, so each call does work proportional to the list's length. pop() at the end shifts nothing. Python has a structure built for removing from the front, covered in a later lesson; until then, keep lists for stacks.
What does this program print?
Python list methods follow a convention: a method that changes the list in place returns None. That None signals that the work happened to the object you called it on. Functions and operators that leave the original alone hand back something new instead. Knowing which group a call belongs to tells you both what happens to your list and what you get back.
1numbers = [3, 1, 4, 1, 5]2result = numbers.sort()3print("result:", result)4print("numbers:", numbers)
numbers.sort() reordered numbers and returned None, which landed in result. To keep the original order and get a sorted version, call the built-in sorted(), which reads any iterable and returns a new list.
1original = [3, 1, 4, 1, 5]2ordered = sorted(original)3print("original:", original)4print("ordered:", ordered)5print("same object:", original is ordered)
| Call | Changes the list? | Returns |
|---|---|---|
| `lst.append(x)`, `lst.extend(xs)`, `lst.insert(i, x)` | Yes | `None` |
| `lst.remove(x)`, `lst.clear()` | Yes | `None` |
| `lst.sort()`, `lst.reverse()` | Yes | `None` |
| `lst.pop()` | Yes | The removed item |
| `lst.copy()`, `lst[a:b]`, `lst + other` | No | A new list |
| `sorted(lst)` | No | A new list |
| `reversed(lst)` | No | An iterator over the list |
| `lst.index(x)`, `lst.count(x)` | No | A number |
pop() is the in-place method with a useful return value, the item it removed, and stacks rely on it. reversed(lst) returns an iterator that reads the list backwards on demand; wrap it in list() to get a new list.
Assigning the result of an in-place call
Writing rows = rows.append(x) stores the None that append returned, and the name no longer reaches the list. The next method call on rows fails, often several lines after the line with the mistake.
+= changes the list, + makes a new one
For lists, a += b extends a in place, the same as a.extend(b), and every alias sees the new items. a = a + b builds a new list and points a at it, so aliases keep the old list. The augmented assignment you learned on numbers behaves differently here because a list can change in place and an integer cannot.
Choosing a form in a pipeline
- Your code owns the list and no other name needs the old contents
- The list is large and a second copy would double its memory
- The call returns None, so write it as its own statement
- The input must survive, such as a raw batch kept for auditing
- A caller passed the list in and didn't ask for changes
- You want the result back as the return value of the call
None, or leaves the argument alone and returns a new list. The drop_empty helper from earlier did both, which is why its caller couldn't tell the raw batch had changed.Predict the output.
Capstone: a plan with undo
What does this program print?
The in-place rule bites hardest when you sort nested data. row.sort() reorders whichever list row names, so if a working copy shares its inner lists with the source, sorting the copy's rows reorders the source's rows too. The copy has to reach the inner lists, the 2-level pattern from the deep copy section, before any in-place call runs.
> Back in the Instagram signup job, a template list of lists holds the default story settings, and every account created that hour needs settings it can edit without touching any other account, plus an undo for its own last change.
template or template.copy() shares the inner lists, so a single account turning off replies changes all 40,000.copy.deepcopy(template), or [row[:] for row in template] while the template stays 2 levels deep, gives every account its own inner lists.[[...]] * n avoids repeated references to a single inner list.settings.sort() returns None, so the job writes it as a statement and uses sorted() wherever the template's order must survive.if filters items, an if-else in front transforms them, and for clauses run in nested-loop order.copy() makes a new outer list that still shares every inner list, and so do [:] and list(). [[0] * 3] * 3 repeats a single row.copy.deepcopy duplicates every level and keeps internal sharing intact, at a cost that grows with everything inside.append and pop() at the end of a list make a stack. Undo and bracket matching both rely on one, and so does walking nested data without recursion.None. sorted() returns a new list, as do + and slicing, while += on a list extends it in place.Build lists in a single expression, copy them without hidden sharing, and predict which calls change the original.
- Category
- Python
- Difficulty
- advanced
- Duration
- 36 minutes
- Challenges
- 21 hands-on challenges
Topics covered: List Comprehensions, References and Shallow Copies, Deep Copies, Lists as Stacks, In-Place vs Return Value
Lesson Sections
- List Comprehensions (concepts: List Comprehension)
Filtering with a trailing if Transforming with if-else in front Both can appear together: ["big" if c > 10000 else "small" for c in xs if c > 0] drops non-positive amounts, then labels the rest. Several for clauses The loop variable stays inside When a loop reads better
- References and Shallow Copies (concepts: Copying Lists)
Mutating the object vs rebinding the name Functions get the caller's list Passing a list to a function binds the parameter to the object the caller holds. No copy happens at the call. A cleaning helper that edits its argument therefore edits the caller's data, which is how a raw batch kept for auditing ends up already cleaned. Shallow copies: a new outer list Multiplying a list of lists Default arguments are built once
- Deep Copies (concepts: Copying Lists)
Equal contents, separate objects Shared references survive the copy Objects inside lists What a deep copy costs
- Lists as Stacks (concepts: Stack & Queue)
Undo is a stack Undo needs the most recent change first, which is the order a stack returns. Before each edit, push the current value. Undoing pops the last saved value and restores it. Rolling back a sequence of applied schema changes has the same shape: each applied change is pushed, and a rollback pops them newest first. Matching brackets Walking nested data without recursion A recursive function keeps its unfinished work on Python's call stack. Holding that work in a list instead avoids the
- In-Place vs Return Value (concepts: List Modification)
Assigning the result of an in-place call += changes the list, + makes a new one Choosing a form in a pipeline Capstone: a plan with undo The last problems use everything from the earlier sections. You'll see shallow and deep copies, including one made by a comprehension, next to a stack of saved states and calls that either change a list or return a new one. Trace each name to the object it points at before you answer.