The Story That Explains a Stack
Nobody climbs over anyone. Nobody teleports to the middle. The rule is absolute: last in, first out (LIFO). Every person who needs to leave must exit through the same opening they entered — no back-door, no random access.
That is a stack. The cinema row is your memory. Sitting down is push(). Standing up to leave is pop(). The wall at the far end makes random access impossible — by design.
A stack is a linear data structure that enforces LIFO order. Two operations define it completely: push adds an item to the top, and pop removes the item from the top. Everything else — size, peek, isEmpty — is a convenience wrapper around those two primitives.
LIFO is not a limitation — it is a guarantee. Undo systems, function call frames, bracket validators, and expression parsers all depend on the fact that the most recent thing is always handled first. Random access would break every one of those use-cases.
Push & Pop — Step-by-Step Walkthrough
Walk through each operation below to build an intuition before touching any code. The "stack" starts empty; items are added and removed one at a time.
[ ]
[ 10 ] ← top
[ 10, 20 ] ← top
[ 10, 20, 30 ] ← top
[ 10, 20, 30 ] ← top (no change)
[ 10, 20 ] ← top
[ 10 ] ← top
[ ]
Both push and pop
run in O(1) constant time regardless of stack size. There is no searching,
no shifting, no re-indexing. The top pointer moves by exactly one step — that is it.
Python Implementation — Three Approaches
Python does not ship a dedicated stack class, but three clean idiomatic approaches exist. Pick the right tool based on whether thread-safety or raw speed matters most.
listappend() as push and pop() as pop. Both are O(1) amortised. Perfectly fine for single-threaded code.# ── Approach 1: Python list as a stack ───────────────────────
stack = []
# push — O(1) amortised
stack.append(10)
stack.append(20)
stack.append(30)
print("After 3 pushes:", stack) # [10, 20, 30]
# peek — look without removing
top = stack[-1]
print("Peek:", top) # 30
# pop — O(1)
val = stack.pop()
print("Popped:", val) # 30
print("Stack now:", stack) # [10, 20]
# safe pop — guard against underflow
def safe_pop(s):
if not s:
raise IndexError("pop from empty stack")
return s.pop()
print(safe_pop(stack)) # 20
print(safe_pop(stack)) # 10
list.insert(0, …) or list.pop(0)Inserting or removing at index 0 shifts every element — that is O(n). Always append and pop from the right end (the default) to preserve O(1) behaviour. This is the single most common stack mistake in interview code.
collections.dequefrom collections import deque
stack = deque()
stack.append("frame_main")
stack.append("frame_foo")
stack.append("frame_bar")
print("Call stack:", stack)
print("Top frame :", stack[-1]) # peek
while stack:
frame = stack.pop()
print(f"Returning from {frame}")
Stack Classclass Stack:
def __init__(self, capacity=None):
self._data = []
self._capacity = capacity
def push(self, item):
if self._capacity and len(self._data) >= self._capacity:
raise OverflowError(f"Stack overflow — max capacity {self._capacity}")
self._data.append(item)
def pop(self):
if self.is_empty():
raise IndexError("pop from empty stack")
return self._data.pop()
def peek(self):
if self.is_empty():
raise IndexError("peek on empty stack")
return self._data[-1]
def is_empty(self): return len(self._data) == 0
def size(self): return len(self._data)
def __repr__(self): return f"Stack({self._data})"
# ── usage ─────────────────────────────────────────────────────
s = Stack(capacity=5)
for n in [1, 2, 3]:
s.push(n)
print(s) # Stack([1, 2, 3])
print("peek:", s.peek()) # peek: 3
print("pop :", s.pop()) # pop : 3
print("size:", s.size()) # size: 2
# overflow guard
try:
for i in range(10): s.push(i)
except OverflowError as e:
print(f"Caught: {e}")
For quick scripts and algorithm practice, a bare list is
perfectly idiomatic. For production code or anything shared across a team, wrap it in
a class — the underflow error message alone saves hours of debugging. Use
deque when you genuinely need O(1) on both ends (e.g. a
sliding-window buffer that doubles as a stack).
Time & Space Complexity
| Operation | Time (list) | Time (deque) | Worst-Case? | Notes |
|---|---|---|---|---|
| push | O(1) amort. | O(1) | O(n) resize | Rare — only when internal array doubles |
| pop | O(1) | O(1) | O(1) | Always constant — no shifting required |
| peek | O(1) | O(1) | O(1) | Index access to last element |
| isEmpty | O(1) | O(1) | O(1) | len(s) == 0 |
| search / access middle | O(n) | O(n) | O(n) | Must pop everything above target — by design |
| Space | O(n) | O(n) | O(n) | Proportional to number of elements |
Python's list doubles in size when it runs out of space,
which occasionally costs O(n). But averaged over all pushes, the cost per push is O(1).
In interview settings, say "O(1) amortised" — it shows you understand
the internals. For latency-sensitive real-time systems, use deque
to guarantee O(1) without any surprise spikes.
Where Stacks Appear in the Real World
Stacks are one of the most ubiquitous structures in computer science. Every time you see "most recent first" or "reverse order", there is a very good chance a stack is behind it.
foo() calls bar(), the CPU pushes foo's frame. When bar() returns, it pops back. The Python traceback is the call stack printed top-to-bottom.Worked Example — Bracket Validator
def is_valid(s: str) -> bool:
"""Return True if all brackets in s are correctly nested."""
stack = []
pairs = {')': '(', '}': '{', ']': '['}
for ch in s:
if ch in '([{':
stack.append(ch) # push opening bracket
elif ch in ')]}':
if not stack or stack[-1] != pairs[ch]:
return False # mismatch or underflow
stack.pop() # matched — remove
return len(stack) == 0 # empty stack = all closed
# ── test cases ────────────────────────────────────────────────
tests = [
("()[]{}", True),
("([{}])", True),
("(]", False),
("{[}", False),
("", True),
("(((", False),
]
for expr, expected in tests:
result = is_valid(expr)
status = "✓" if result == expected else "✗"
print(f"{status} is_valid({repr(expr):10s}) = {result}")
Worked Example — Iterative DFS with an Explicit Stack
def dfs_iterative(graph: dict, start):
"""Depth-first search using an explicit stack instead of recursion."""
visited = set()
stack = [start] # push start node
order = []
while stack:
node = stack.pop() # LIFO — go deep first
if node not in visited:
visited.add(node)
order.append(node)
for neighbour in reversed(graph[node]):
if neighbour not in visited:
stack.append(neighbour)
return order
graph = {
'A': ['B', 'C'],
'B': ['D', 'E'],
'C': ['F'],
'D': [], 'E': [], 'F': []
}
print(dfs_iterative(graph, 'A'))
Every recursive DFS call is a push onto Python's call stack. The iterative version above makes that explicit. For very deep graphs, the iterative form avoids Python's default recursion limit of 1000 frames (sys.setrecursionlimit). This matters in competitive programming and large graph problems.
Before & After — Naive vs Stack-Based Undo
One of the clearest demonstrations of why stacks matter is implementing undo. Compare the naive approach (re-running history from scratch) against the stack approach.
| Step | Action | Problem |
|---|---|---|
| 1 | Record all edits in a list | |
| 2 | Ctrl+Z pressed | |
| 3 | Remove last edit from list | |
| 4 | Replay entire list from scratch | O(n) per undo |
| 5 | Document at step 100, Ctrl+Z ×50 | 50 × O(n) replays |
| Step | Action | Benefit |
|---|---|---|
| 1 | Each edit pushes a delta onto undo stack | |
| 2 | Ctrl+Z pressed | |
| 3 | pop() returns the last delta | O(1) |
| 4 | Apply the reverse of the delta | O(1) |
| 5 | Push delta onto redo stack | O(1) redo too |
Common Pitfalls — The Four Traps
list.pop()
raises IndexError silently — it does not say "underflow".list.pop(0) removes the front, shifting every
other element. This makes push O(n). Always pop from the right end (the default).
If you need FIFO, switch to collections.deque.
RecursionError: maximum recursion depth exceeded.
The default limit is 1000. Add a base case, or convert to iterative with an
explicit stack for deep problems.
Golden Rules
if not stack or
call is_empty() before every pop() or peek().
Stack underflow is a silent bug — Python's IndexError gives no
stack-specific context unless you add it yourself.
append() + pop() is O(1).
The moment you touch index 0 with insert or pop, you have broken the O(1) guarantee.
deque when you need guaranteed O(1), not amortised O(1).
Python's list occasionally doubles in memory (O(n) spike).
collections.deque avoids this. For latency-sensitive code — real-time
systems, games, audio — always use deque.
size() assertion in tests to catch asymmetric push/pop patterns early.