Python Data Structures Interview Questions — Lists, Dicts, Sets & More
Data structures aren't just a Computer Science exam topic — they're the daily tools every Python developer reaches for without thinking. Whether you're building a REST API, scraping a website, or crunching data in pandas, the choice between a list and a set, or a dict and a defaultdict, changes how fast your code runs and how readable it stays. Interviewers love data structure questions because they reveal whether you understand trade-offs, not just syntax.
The real problem most candidates have isn't that they don't know what a list is. It's that they can't articulate WHY they'd choose one structure over another under pressure, and they miss the subtle gotchas — mutability, hashing, ordering guarantees — that separate junior answers from senior ones.
By the end of this article you'll be able to answer questions about time complexity, pick the right structure for a given problem, explain the internals of dicts and sets in plain English, and dodge the three most common mistakes candidates make mid-interview. Let's build this up piece by piece.
Lists vs Tuples — It's Not Just About Mutability
Every candidate says 'lists are mutable, tuples are immutable' — and then the interviewer nods politely and waits for more. That answer is correct but incomplete.
The deeper WHY: because tuples are immutable, Python can store them more efficiently in memory and use them as dictionary keys or set members. A list can never be a dict key — try it and you'll get a TypeError. Tuples also communicate intent: when you return (latitude, longitude) from a function, you're signalling to the next developer 'these two values belong together and shouldn't be shuffled around'.
Lists shine when your collection needs to grow, shrink, or be sorted — think a queue of incoming HTTP requests or a shopping cart. Tuples shine when you want a fixed record — RGB colours, database row results, coordinate pairs.
Appending to a list is O(1) amortised — Python over-allocates memory in chunks, so you're not copying the whole list on every append. Inserting at position 0 is O(n), because every element shifts right. That detail trips up a lot of candidates.
# Demonstrating WHY tuples can be dict keys and lists cannot # A tuple can represent a fixed geographic coordinate office_location = (51.5074, -0.1278) # latitude, longitude of London # Tuples are hashable, so they work as dictionary keys location_names = { (51.5074, -0.1278): "London HQ", (40.7128, -74.0060): "New York Office", (35.6762, 139.6503): "Tokyo Branch", } print(location_names[office_location]) # Works perfectly # Now try the same with a list — this will blow up try: bad_key = [51.5074, -0.1278] # a list version of the same coords bad_dict = {bad_key: "This will never work"} except TypeError as error: print(f"TypeError caught: {error}") # Demonstrating amortised O(1) append vs O(n) insert at front import time request_queue = [] # Appending 100,000 items — fast, O(1) amortised each start = time.perf_counter() for request_id in range(100_000): request_queue.append(request_id) # adds to the END — cheap append_time = time.perf_counter() - start # Inserting at position 0 — slow, O(n) each time front_insert_queue = [] start = time.perf_counter() for request_id in range(10_000): # only 10k — already slower front_insert_queue.insert(0, request_id) # shifts EVERY element right insert_time = time.perf_counter() - start print(f"100k appends: {append_time:.4f}s") print(f" 10k front-inserts: {insert_time:.4f}s") # notice 10x fewer items, yet slower print("Use collections.deque if you need fast front insertion!")
TypeError caught: unhashable type: 'list'
100k appends: 0.0062s
10k front-inserts: 0.0271s
Use collections.deque if you need fast front insertion!
Dictionaries and Sets — Hash Tables Under the Hood
Python dicts and sets are both built on hash tables — and understanding that one fact makes you dangerous in interviews.
Here's the mental model: when you do user_scores['alice'], Python doesn't scan the entire dict looking for 'alice'. It runs hash('alice'), gets a number, jumps directly to the corresponding memory bucket, and returns the value. That's why dict lookup is O(1) — it's a single mathematical jump, not a search.
Sets work identically but only store keys, no values. That's why 'alice' in large_set is blindingly fast while 'alice' in large_list gets slower as the list grows.
The critical interview implication: only hashable objects can be dict keys or set members. Integers, strings, and tuples are hashable. Lists and dicts are not — they're mutable, so their hash could change after insertion, which would corrupt the hash table.
Since Python 3.7, dicts maintain insertion order as a language guarantee — not an implementation detail. That matters when you're iterating and need predictable output.
from collections import defaultdict, Counter # ── EXAMPLE 1: O(1) lookup power ────────────────────────────────────────── # Imagine checking if a username is taken taken_usernames = {"alice", "bob", "carol", "dave"} # a set — no values needed print("'alice' taken?", "alice" in taken_usernames) # O(1) — hash jump print("'zara' taken?", "zara" in taken_usernames) # O(1) — hash jump # Compare: same check with a list is O(n) — scans every element taken_list = ["alice", "bob", "carol", "dave"] print("'alice' in list?", "alice" in taken_list) # works, but slower at scale # ── EXAMPLE 2: defaultdict removes the 'key exists?' dance ──────────────── # Counting word frequency the old way (lots of boilerplate) sentence = "the cat sat on the mat and the cat sat" word_frequency_old = {} for word in sentence.split(): if word in word_frequency_old: # have to check first word_frequency_old[word] += 1 else: word_frequency_old[word] = 1 # initialise manually # The clean way with defaultdict word_frequency = defaultdict(int) # int() returns 0 — perfect default for word in sentence.split(): word_frequency[word] += 1 # no existence check needed print("\nWord frequencies:", dict(word_frequency)) # ── EXAMPLE 3: Counter — the specialised frequency dict ────────────────── votes = ["alice", "bob", "alice", "carol", "alice", "bob"] vote_tally = Counter(votes) # built for exactly this use case print("\nVote tally:", vote_tally) print("Top candidate:", vote_tally.most_common(1)) # returns [(name, count)] # ── EXAMPLE 4: Set operations — the interview favourite ────────────────── premium_users = {"alice", "carol", "eve"} active_users = {"alice", "bob", "carol", "dave"} print("\nPremium AND active:", premium_users & active_users) # intersection print("Premium OR active:", premium_users | active_users) # union print("Premium NOT active:", premium_users - active_users) # difference print("Active NOT premium:", active_users - premium_users) # difference (flipped)
'zara' taken? False
'alice' in list? True
Word frequencies: {'the': 3, 'cat': 2, 'sat': 2, 'on': 1, 'mat': 1, 'and': 1}
Vote tally: Counter({'alice': 3, 'bob': 2, 'carol': 1})
Top candidate: [('alice', 3)]
Premium AND active: {'alice', 'carol'}
Premium OR active: {'alice', 'bob', 'carol', 'dave', 'eve'}
Premium NOT active: {'eve'}
Active NOT premium: {'bob', 'dave'}
Stacks, Queues, and When the Built-ins Aren't Enough
Python doesn't have built-in Stack or Queue classes — and that confuses candidates who learned data structures in Java or C++. The Pythonic answer is: use what already exists, but use it correctly.
A list works fine as a stack (LIFO): append() to push, pop() to pop — both O(1). But using a list as a queue is a performance trap. list.pop(0) removes from the front and is O(n) because every remaining element shifts.
For queues (FIFO), use collections.deque. It's a doubly-linked list under the hood — appendleft and popleft are O(1). For priority queues — where you want to always retrieve the smallest (or largest) item first — use the heapq module. This comes up constantly in real interviews, especially graph problems like Dijkstra's algorithm.
Knowing the right tool signals that you've actually used Python in production, not just read a tutorial.
from collections import deque import heapq # ── STACK (LIFO) — use a plain list ─────────────────────────────────────── # Real-world: undo history in a text editor undo_history = [] # empty stack undo_history.append("typed 'Hello'") undo_history.append("typed ' World'") undo_history.append("deleted last word") print("Undo stack:", undo_history) last_action = undo_history.pop() # O(1) — removes from end print("Undoing:", last_action) print("Stack after undo:", undo_history) # ── QUEUE (FIFO) — use deque, NOT a list ────────────────────────────────── # Real-world: print job queue — first submitted, first printed print_queue = deque() print_queue.append("Report.pdf") # appendright — O(1) print_queue.append("Invoice.pdf") print_queue.append("Resume.pdf") print("\nPrint queue:", print_queue) next_job = print_queue.popleft() # O(1) — removes from front print("Now printing:", next_job) print("Remaining queue:", print_queue) # ── PRIORITY QUEUE — use heapq ──────────────────────────────────────────── # Real-world: support ticket system — severity 1 is most urgent # heapq is a MIN-heap: smallest value pops first support_tickets = [] # Each tuple: (priority, ticket_id, description) # Tuple comparison checks first element first — so priority drives ordering heapq.heappush(support_tickets, (3, 101, "Slow page load")) heapq.heappush(support_tickets, (1, 102, "Site is DOWN — all users affected")) heapq.heappush(support_tickets, (2, 103, "Login button misaligned")) heapq.heappush(support_tickets, (1, 104, "Payment processing failing")) print("\nProcessing tickets by priority:") while support_tickets: priority, ticket_id, description = heapq.heappop(support_tickets) # always pops min print(f" Priority {priority} | #{ticket_id}: {description}")
Undoing: deleted last word
Stack after undo: ["typed 'Hello'", "typed ' World'"]
Print queue: deque(['Report.pdf', 'Invoice.pdf', 'Resume.pdf'])
Now printing: Report.pdf
Remaining queue: deque(['Invoice.pdf', 'Resume.pdf'])
Processing tickets by priority:
Priority 1 | #102: Site is DOWN — all users affected
Priority 1 | #104: Payment processing failing
Priority 2 | #103: Login button misaligned
Priority 3 | #101: Slow page load
List Comprehensions, Memory, and Generator Expressions
List comprehensions are one of Python's most loved features — and one of the most misused. The interview trap isn't 'can you write one?' — it's 'do you know when NOT to use one?'
A list comprehension builds the entire list in memory at once. If you're filtering a million database records to find ten matches, you've just loaded a million items into RAM before discarding 999,990 of them. That's wasteful.
Generator expressions solve this. They look almost identical to list comprehensions but use parentheses instead of square brackets. Crucially, they're lazy — they compute each value only when you ask for it. Memory usage stays flat regardless of input size.
For interview questions about large datasets, memory efficiency, or streaming data, generator expressions are almost always the right answer. Interviewers listen carefully for whether you distinguish between the two.
import sys # ── List comprehension: builds everything in memory immediately ──────────── # Imagine fetching all user IDs from a large database table all_user_ids = range(1, 1_000_001) # pretend these come from a DB # List comprehension — every filtered ID sits in RAM right now active_users_list = [uid for uid in all_user_ids if uid % 2 == 0] # even IDs only print(f"List size in memory: {sys.getsizeof(active_users_list):,} bytes") # ── Generator expression: computes lazily, one item at a time ───────────── # Same filter, but items are generated only when consumed active_users_gen = (uid for uid in all_user_ids if uid % 2 == 0) print(f"Generator size in memory: {sys.getsizeof(active_users_gen):,} bytes") print("(The generator object is tiny — the data isn't loaded yet)") # ── When you NEED a list comprehension (multiple passes, indexing) ───────── temperature_readings = [22.5, 19.0, 30.1, 17.8, 25.3, 28.6] # We need to compute average THEN filter — two passes over the data average_temp = sum(temperature_readings) / len(temperature_readings) hot_days = [temp for temp in temperature_readings if temp > average_temp] print(f"\nAverage temperature: {average_temp:.1f}°C") print(f"Above-average days: {hot_days}") # ── Nested comprehension — common interview question ────────────────────── # Flatten a 2D grid of sensor readings into a single list sensor_grid = [ [12, 15, 11], # row 0: building floor 1 [18, 22, 19], # row 1: building floor 2 [9, 13, 10], # row 2: building floor 3 ] # Read: "give me each reading, for each row, for each reading in that row" all_readings = [reading for row in sensor_grid for reading in row] print(f"\nAll sensor readings: {all_readings}") print(f"Max reading: {max(all_readings)}, Min reading: {min(all_readings)}")
Generator size in memory: 208 bytes
(The generator object is tiny — the data isn't loaded yet)
Average temperature: 23.9°C
Above-average days: [30.1, 25.3, 28.6]
All sensor readings: [12, 15, 11, 18, 22, 19, 9, 13, 10]
Max reading: 22, Min reading: 9
| Feature / Aspect | list | tuple | dict | set | deque |
|---|---|---|---|---|---|
| Ordered? | Yes (insertion order) | Yes (insertion order) | Yes (Python 3.7+) | No | Yes (insertion order) |
| Mutable? | Yes | No | Yes | Yes | Yes |
| Allows duplicates? | Yes | Yes | Keys: No, Values: Yes | No | Yes |
| Lookup time complexity | O(n) for search | O(n) for search | O(1) by key | O(1) membership | O(n) for search |
| Append / Push | O(1) amortised | N/A — immutable | O(1) | O(1) | O(1) both ends |
| Insert at front | O(n) — avoid! | N/A | N/A | N/A | O(1) — use deque! |
| Hashable (dict key)? | No | Yes (if contents hashable) | No | No | No |
| Best real-world use | Ordered, growable collection | Fixed record / DB row | Lookup by name / key | Deduplication / membership | Queue / sliding window |
🎯 Key Takeaways
- Tuple vs list isn't just about mutability — tuples are hashable and can be dict keys; lists cannot. Use tuples for fixed records, lists for growable collections.
- Dict and set lookups are O(1) because they use hash tables — Python computes a hash, jumps to a memory location, done. List search is O(n). This single fact drives most data structure decisions in real code.
- Never use list.pop(0) for queue behaviour — it's O(n). Use collections.deque.popleft() instead, which is O(1) and built for exactly this purpose.
- List comprehensions build everything in memory immediately. For large or streaming datasets, generator expressions (parentheses instead of brackets) compute lazily and use a constant 200 bytes of memory regardless of input size.
⚠ Common Mistakes to Avoid
- ✕Mistake 1: Using a list as a queue with pop(0) — Every pop(0) call is O(n) because Python shifts every remaining element left by one position. On a list with 100,000 items, that's 100,000 moves per pop. Fix: switch to collections.deque and use popleft() — it's O(1) because deque is a doubly-linked list designed for this exact operation.
- ✕Mistake 2: Mutating a list while iterating over it — Removing or adding items inside a for loop that's iterating the same list causes skipped elements or IndexErrors. Python adjusts the index after each removal, so the next item jumps past. Fix: iterate over a copy (for item in my_list[:]) or build a new list with a comprehension: my_list = [x for x in my_list if condition(x)].
- ✕Mistake 3: Using a mutable default argument in a function — def add_item(item, cart=[]) looks harmless but the empty list is created ONCE when the function is defined, not each time it's called. Every call that uses the default shares the SAME list object, so items accumulate across calls. Fix: use def add_item(item, cart=None) and then cart = [] if cart is None inside the function body.
Interview Questions on This Topic
- QYou need to count the frequency of words in a 5GB log file that won't fit in RAM. Walk me through your approach — what data structures would you use and why?
- QWhat's the time complexity of checking membership in a list versus a set, and can you explain WHY the difference exists at the implementation level?
- QIf I give you two lists of user IDs — one from yesterday's active users and one from today's — how would you find users who were active yesterday but not today, and which data structure makes that operation most efficient?
Frequently Asked Questions
What is the difference between a list and a tuple in Python for interviews?
Lists are mutable and cannot be used as dictionary keys. Tuples are immutable and hashable, so they can be dict keys and set members. Beyond mutability, tuples communicate intent — they signal a fixed record like a coordinate or a database row. In interviews, always mention hashability — it's the detail most candidates miss.
Why is dictionary lookup O(1) in Python?
Python dicts are built on hash tables. When you look up a key, Python runs the built-in hash() function on it, which produces an integer that maps to a specific memory bucket. This is a direct mathematical jump — no scanning, no comparison loop. As long as hash collisions are rare (and Python manages them well), lookup stays constant time regardless of how many keys are in the dict.
When should I use a generator expression instead of a list comprehension?
Use a generator expression when you only need to iterate over the results once and the dataset is large. A list comprehension materialises every result in memory at once, which can be gigabytes for large inputs. A generator computes each value on demand, using roughly 200 bytes regardless of size. If you need indexing, slicing, or multiple passes over the results, a list comprehension is the right choice.
Written and reviewed by senior developers with real-world experience across enterprise, startup and open-source projects. Every article on TheCodeForge is written to be clear, accurate and genuinely useful — not just SEO filler.