Python Data Structures Explained: A Hands-On Guide
A few years ago I reviewed a pull request where someone had written a nested loop to check whether items from one list existed in another. It worked fine on their 50-row test file and fell over on production data with 400,000 rows. The fix was a one-line change: wrap one list in set().

That's really what data structures are about. You're picking the right container so your code stays fast, readable, and boring in production.
This guide walks through Python's built-in data structures and the handful of standard library ones you'll use constantly. Everything is runnable. Open a REPL or a scratch file and follow along.
What you need: Python 3.9+ and a text editor. No third-party packages.
Step 1: Lists
A list is an ordered, mutable sequence. It's the first thing everyone reaches for, and for good reason.
tasks = ["write tests", "fix bug #42", "deploy"]
tasks.append("update docs") # add to end
tasks.insert(1, "code review") # add at index 1
last = tasks.pop() # remove and return last
tasks.remove("deploy") # remove first match by value
print(tasks[0]) # first item
print(tasks[-1]) # last item
print(tasks[1:3]) # slice: items at index 1 and 2
Under the hood a Python list is a dynamic array. That gives you:
| Operation | Cost |
|---|---|
lst[i] |
O(1) |
lst.append(x) |
O(1) amortized |
lst.pop() |
O(1) |
lst.pop(0) / lst.insert(0, x)
|
O(n) |
x in lst |
O(n) |
The two rows that bite people are pop(0) and x in lst. We'll fix both later.
List comprehensions
squares = [n * n for n in range(10)]
evens = [n for n in range(20) if n % 2 == 0]
Comprehensions are usually faster than an equivalent for loop with .append(), and most Python developers find them easier to read. The exception is when the logic gets complicated. If you need a comment to explain a comprehension, use a loop.
The aliasing trap
a = [1, 2, 3]
b = a
b.append(4)
print(a) # [1, 2, 3, 4] <- surprise
b = a doesn't copy anything. Both names point to the same list. If you want a copy:
b = a.copy() # or list(a), or a[:]
For nested structures, copy() is shallow, so use copy.deepcopy() when you have lists inside lists.
Step 2: Tuples
A tuple is an ordered, immutable sequence.
point = (3, 7)
x, y = point # unpacking
rgb = (255, 128, 0)
r, g, b = rgb
# Swapping without a temp variable
a, b = 1, 2
a, b = b, a
Use tuples when the data is a fixed record: coordinates, a database row, a function returning several values. Because they're immutable and hashable (if their contents are), tuples can be dictionary keys, while lists can't.
grid = {(0, 0): "start", (3, 4): "goal"}
print(grid[(3, 4)]) # goal
One gotcha: a single-element tuple needs a trailing comma.
not_a_tuple = (5) # just the int 5
a_tuple = (5,) # tuple
For records with named fields, typing.NamedTuple or dataclasses.dataclass will save you from remembering that "index 2 is the email".
Step 3: Dictionaries
Dicts map keys to values, with average O(1) lookup, insert, and delete. They also preserve insertion order (guaranteed since Python 3.7). You'll use them constantly.
user = {"name": "Asha", "role": "backend", "active": True}
user["team"] = "payments" # add
print(user.get("email")) # None, no KeyError
print(user.get("email", "n/a")) # n/a
for key, value in user.items():
print(key, value)
Counting things
This comes up all the time, and there are three ways to do it. Here's the progression:
words = "the quick brown fox jumps over the lazy dog the end".split()
# 1. Manual
counts = {}
for w in words:
if w in counts:
counts[w] += 1
else:
counts[w] = 1
# 2. .get()
counts = {}
for w in words:
counts[w] = counts.get(w, 0) + 1
# 3. Counter (the one you should actually use)
from collections import Counter
counts = Counter(words)
print(counts.most_common(2)) # [('the', 3), ('quick', 1)]
Grouping with defaultdict
from collections import defaultdict
by_length = defaultdict(list)
for w in words:
by_length[len(w)].append(w)
No if key not in d boilerplate. A missing key automatically gets an empty list.
Dict comprehensions and merging
squares = {n: n * n for n in range(5)}
defaults = {"theme": "dark", "lang": "en"}
overrides = {"lang": "fr"}
merged = defaults | overrides # Python 3.9+
Keys must be hashable. Strings, numbers, and tuples of those are fine. Lists and dicts aren't.
Step 4: Sets
A set is an unordered collection of unique, hashable items with O(1) average membership tests.
seen = {1, 2, 3}
seen.add(3) # no effect, already there
seen.add(4)
print(2 in seen) # True, and fast
a = {1, 2, 3, 4}
b = {3, 4, 5}
print(a & b) # intersection: {3, 4}
print(a | b) # union: {1, 2, 3, 4, 5}
print(a - b) # difference: {1, 2}
print(a ^ b) # symmetric difference: {1, 2, 5}
The classic use is deduplication:
emails = ["a@x.com", "b@x.com", "a@x.com"]
unique = list(set(emails)) # order is NOT preserved
If you need to keep the original order:
unique = list(dict.fromkeys(emails))
That works because dicts keep insertion order and keys are unique.
Also, {} creates an empty dict, not an empty set. For an empty set you write set().
Step 5: Stacks and Queues
Python has no dedicated Stack or Queue class in the builtins, but you don't need one.
Stack (LIFO): use a list
stack = []
stack.append("page1")
stack.append("page2")
stack.append("page3")
print(stack.pop()) # page3
A good real-world use is bracket matching:
def is_balanced(s: str) -> bool:
pairs = {")": "(", "]": "[", "}": "{"}
stack = []
for ch in s:
if ch in "([{":
stack.append(ch)
elif ch in pairs:
if not stack or stack.pop() != pairs[ch]:
return False
return not stack
print(is_balanced("{[()]}")) # True
print(is_balanced("{[(])}")) # False
Queue (FIFO): use collections.deque
Remember how list.pop(0) is O(n)? For queues, use a deque instead:
from collections import deque
queue = deque()
queue.append("job1")
queue.append("job2")
queue.append("job3")
print(queue.popleft()) # job1, O(1)
A deque is a double-ended queue, so appending and popping from either end are both O(1). It also takes maxlen, which is handy for "keep only the last N items":
recent = deque(maxlen=3)
for i in range(6):
recent.append(i)
print(recent) # deque([3, 4, 5], maxlen=3)
Breadth-first search is the textbook deque use case:
def bfs(graph, start):
visited = {start}
queue = deque([start])
order = []
while queue:
node = queue.popleft()
order.append(node)
for neighbor in graph[node]:
if neighbor not in visited:
visited.add(neighbor)
queue.append(neighbor)
return order
graph = {"A": ["B", "C"], "B": ["D"], "C": ["D"], "D": []}
print(bfs(graph, "A")) # ['A', 'B', 'C', 'D']
Note that visited is a set. That's the same membership-test trick from earlier.
Step 6: Heaps and Sorted Lookups
Two standard library modules are worth knowing early.
heapq: priority queues
heapq gives you a min-heap on top of a plain list. The smallest item is always at index 0.
import heapq
tasks = []
heapq.heappush(tasks, (2, "write report"))
heapq.heappush(tasks, (1, "fix prod outage"))
heapq.heappush(tasks, (3, "refactor"))
while tasks:
priority, name = heapq.heappop(tasks)
print(priority, name)
# 1 fix prod outage
# 2 write report
# 3 refactor
Need the top-k items from a big collection? Don't sort everything:
scores = [88, 92, 79, 95, 60, 99, 71]
print(heapq.nlargest(3, scores)) # [99, 95, 92]
bisect: binary search on sorted lists
import bisect
sorted_ids = [10, 20, 30, 40, 50]
pos = bisect.bisect_left(sorted_ids, 35)
print(pos) # 3
bisect.insort(sorted_ids, 35) # insert and keep sorted
Lookups are O(log n), though insort is still O(n) because of the shifting.
Step 7: Choosing the Right One
When I'm unsure, I ask these questions in order:
| I need... | Use |
|---|---|
| An ordered collection I'll modify | list |
| A fixed record, or a dict key | tuple |
| Lookup by key | dict |
| Uniqueness or fast "is it in here?" | set |
| Counting | Counter |
| Grouping | defaultdict |
| FIFO queue, or "last N items" | deque |
| "Give me the smallest/largest next" | heapq |
| Searching a sorted list | bisect |
If you're doing x in some_list inside a loop, stop and ask whether that list should be a set.
Performance Tips
Don't trust me on any of this. Measure it:
import timeit
data_list = list(range(100_000))
data_set = set(data_list)
print("list:", timeit.timeit("99_999 in data_list", globals=globals(), number=1000))
print("set: ", timeit.timeit("99_999 in data_set", globals=globals(), number=1000))
You'll typically see the set win by several orders of magnitude. The exact numbers depend on your machine, but the gap is always dramatic.
Other things worth knowing:
-
Build strings with
"".join(parts), not repeated+=in a loop. -
Use generators for large data.
sum(x * x for x in data)doesn't build a giant intermediate list. -
Prefer
dict.get()orinover try/except when a miss is common. Exceptions are cheap when rare and slow when frequent. -
Preallocate when the size is known.
[0] * nis much faster than appending n zeros. -
Profile before optimizing.
cProfileandtimeitwill point at the real bottleneck, and it's often not where you'd guess.
The TimeComplexity page on the official Python wiki lists the Big-O cost of every built-in operation. Bookmark it.
Common Errors and Troubleshooting
TypeError: unhashable type: 'list'
You tried to use a list (or dict, or set) as a dict key or set member. Convert it to a tuple: d[tuple(my_list)] = ....
KeyError: 'email'
The key isn't in the dict. Use d.get("email"), check with if "email" in d, or use defaultdict.
RuntimeError: dictionary changed size during iteration
You added or deleted keys while looping over the dict. Iterate over a copy of the keys instead:
for key in list(d.keys()):
if d[key] is None:
del d[key]
Modifying a list while looping over it
This skips elements in confusing ways.
nums = [2, 4, 6, 8]
for n in nums:
if n % 2 == 0:
nums.remove(n)
print(nums) # [4, 8], not [] as you'd expect
Removing an item shifts everything left, and the loop's internal index then skips the next element. Build a new list instead: nums = [n for n in nums if n % 2 != 0].
The [[]] * 3 bug
grid = [[0] * 3] * 3
grid[0][0] = 1
print(grid) # [[1, 0, 0], [1, 0, 0], [1, 0, 0]]
All three rows are the same list object. Do this instead:
grid = [[0] * 3 for _ in range(3)]
Mutable default arguments
def add_item(item, bucket=[]): # bug: one shared list for every call
bucket.append(item)
return bucket
def add_item(item, bucket=None): # fix
if bucket is None:
bucket = []
bucket.append(item)
return bucket
IndexError: pop from empty list / pop from an empty deque
Check if stack: before popping.
Best Practices
- Use the most specific structure. If you only need uniqueness, a set says so to the next reader.
- Keep structures homogeneous. A list of ints or a list of user dicts is easy to reason about. Mixed types get messy quickly.
-
Don't mutate arguments unless the function name makes it obvious (
sort_in_place,append_row). -
Prefer
dataclassorNamedTupleover "dicts of everything" once you pass three or four fields. -
Add type hints.
dict[str, list[int]]documents intent better than a comment. -
Write tiny tests. A structure-heavy function is easy to test with
pytest.
Practical Exercises
Try each one before looking anything up. It's fine if you get stuck. That's when it sticks.
Exercise 1: Deduplicate, keep order.
Given ["b", "a", "b", "c", "a"], return ["b", "a", "c"].
Exercise 2: Two-sum.
Given a list of ints and a target, return the indices of two numbers that add up to the target, in O(n) time. (Hint: a dict of value to index.)
Exercise 3: Anagram groups.
Group ["eat", "tea", "tan", "ate", "nat", "bat"] into lists of anagrams. (Hint: what's a good dict key for "same letters"?)
Exercise 4: Sliding window.
Use a deque(maxlen=k) to compute a moving average over a stream of numbers.
Exercise 5: LRU cache.
Build a small cache with get and put that evicts the least recently used item when full. Start with collections.OrderedDict and its move_to_end method. Then try building it yourself with a dict plus a doubly linked list.
Mini project: a log summarizer
Here's a starter. Extend it however you like.
from collections import Counter, defaultdict
LOG_LINES = [
"10.0.0.1 GET /home 200",
"10.0.0.2 GET /about 200",
"10.0.0.1 POST /login 401",
"10.0.0.3 GET /home 500",
"10.0.0.1 GET /home 200",
]
def summarize(lines):
status_counts = Counter()
paths_by_status = defaultdict(set)
hits_per_ip = Counter()
for line in lines:
ip, method, path, status = line.split()
status_counts[status] += 1
paths_by_status[status].add(path)
hits_per_ip[ip] += 1
return status_counts, paths_by_status, hits_per_ip
statuses, paths, ips = summarize(LOG_LINES)
print(statuses.most_common())
print(dict(paths))
print(ips.most_common(1))
Stretch goals: read from a real file, find the top 3 slowest endpoints using heapq, and flag any IP with more than N failed logins in a sliding window.
GitHub Examples to Read (and Build)
Repos worth reading (search for them by name on GitHub):
-
TheAlgorithms/Pythonhas clean, readable implementations of stacks, queues, linked lists, trees, and graphs. -
keon/algorithmsis another well-organized collection with short examples. -
python/cpython, specificallyLib/collections/__init__.py, shows howOrderedDict,Counter, andnamedtupleare built.
Set up your own practice repo. A structure like this works well:
ds-practice/
├── README.md
├── src/
│ ├── lists_and_tuples.py
│ ├── dict_patterns.py
│ ├── stacks_queues.py
│ └── lru_cache.py
└── tests/
├── test_dict_patterns.py
└── test_lru_cache.py
Commit after each exercise with a message that says what you learned, like "add two-sum with dict, O(n)". A repo full of small commits and passing tests says more about you than a certificate does. It also shows up nicely when you're applying for jobs.
A simple test to get you started:
# tests/test_stacks_queues.py
from src.stacks_queues import is_balanced
def test_balanced():
assert is_balanced("{[()]}")
def test_unbalanced():
assert not is_balanced("{[(])}")
Run with pytest.
Where This Fits in a Bigger Learning Path
Data structures are the foundation, but they're not the whole picture. If you're aiming for full stack work, the usual sequence is: core Python and data structures, then a web framework (Flask, Django, or FastAPI), then SQL and an ORM, then a frontend basics layer (HTML, CSS, JavaScript, maybe React), and finally Git, Docker, and deployment.
If you're in a city like Bengaluru and searching for something like "python full stack training in bangalore" or the "best python full stack training in bangalore", it's worth looking past the ads. Here's what I'd check for any program:
- Do you build and deploy real projects, or only watch demos?
- Are data structures and SQL taught properly, or skipped?
- Do instructors review your code, or just your attendance?
- Can you see recent student projects on GitHub?
- Are the tools current (Python 3.11+, a modern framework, Git-based workflow)?
The structure of a course matters much less than how many hours you spend writing code and debugging your own mistakes. Plenty of people get there with free resources and a strict routine, and others do better with the accountability of a classroom. Either path works if you build things.
Learning Resources
- Python docs: Data Structures tutorial is short and authoritative.
- Python wiki: TimeComplexity is the Big-O cheat sheet.
- Real Python has practical articles on dicts, sets, deques, and heaps.
- Python Tutor lets you visualize step by step what your code does in memory, which is great for understanding aliasing bugs.
- Fluent Python by Luciano Ramalho goes deep into how these structures actually work.
- Problem Solving with Algorithms and Data Structures using Python (the Runestone Academy book) is free online and covers the classic structures you'd implement by hand.
- Coding Interview University (a GitHub repo by John Washam) is a long study plan if you're targeting interviews.
Wrapping Up
If you remember only four things from this post:
- Use a set or dict when you're checking membership.
- Use a deque when you're popping from the front.
- Use Counter and defaultdict instead of hand-rolled counting logic.
- Measure before you optimize.
Everything else is practice. Do the exercises, break things on purpose, and read other people's code.
What's the data structure bug that cost you the most time? Mine was the [[0] * 3] * 3 grid, and I'd like to hear yours in the comments.
Top comments (0)