Home Interview Python Data Structures Interview Questions — Lists, Dicts, Sets & More

Python Data Structures Interview Questions — Lists, Dicts, Sets & More

In Plain English 🔥
Imagine you're organising a party. A list is your ordered guest list — everyone has a seat number. A dictionary is the name badge table — you look up a person by name, not by seat. A set is the unique-gift pile — no duplicates allowed. A tuple is the printed invitation — once it's sent, you can't change it. Python's data structures are just these everyday organising ideas, formalised into code.
⚡ Quick Answer
Imagine you're organising a party. A list is your ordered guest list — everyone has a seat number. A dictionary is the name badge table — you look up a person by name, not by seat. A set is the unique-gift pile — no duplicates allowed. A tuple is the printed invitation — once it's sent, you can't change it. Python's data structures are just these everyday organising ideas, formalised into code.

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.

list_vs_tuple_demo.py · PYTHON
123456789101112131415161718192021222324252627282930313233343536373839404142
# 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!")
▶ Output
London HQ
TypeError caught: unhashable type: 'list'
100k appends: 0.0062s
10k front-inserts: 0.0271s
Use collections.deque if you need fast front insertion!
⚠️
Interview Gold:When an interviewer asks 'what's the difference between a list and a tuple?', go beyond mutability. Say: 'Tuples are hashable, so they can be dict keys and set members. They also signal to other developers that this is a fixed record, not a collection to be modified. And because Python knows a tuple won't change, it can allocate memory more efficiently.' That answer gets you to the next round.

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.

dict_and_set_internals.py · PYTHON
123456789101112131415161718192021222324252627282930313233343536373839404142434445
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)
▶ Output
'alice' taken? True
'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'}
⚠️
Watch Out:Dict keys must be hashable — but that doesn't mean ALL immutable types are hashable. A tuple of lists like `([1,2], [3,4])` is immutable at the tuple level but contains mutable objects inside, so it's NOT hashable and will raise a TypeError if used as a dict key. Always ask: does every element inside the tuple hash cleanly?

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.

stack_queue_heap_demo.py · PYTHON
12345678910111213141516171819202122232425262728293031323334353637383940414243
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}")
▶ Output
Undo stack: ["typed 'Hello'", "typed ' World'", 'deleted last word']
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
🔥
Pro Tip:heapq is a min-heap. To simulate a max-heap (always pop the LARGEST item first), store your values as negatives: push `-priority` instead of `priority`, then negate when you pop. It's a classic interview trick that shows you understand the underlying structure, not just the API.

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.

comprehension_vs_generator.py · PYTHON
12345678910111213141516171819202122232425262728293031323334353637383940
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)}")
▶ Output
List size in memory: 4,189,000 bytes
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
⚠️
Interview Gold:If an interviewer asks 'how would you process a file with 10 million lines?', the answer is NOT a list comprehension. Say: 'I'd use a generator expression or iterate line-by-line with a for loop, so only one line lives in memory at a time. A list comprehension would load all 10 million lines at once and risk an out-of-memory crash.' That shows production awareness.
Feature / Aspectlisttupledictsetdeque
Ordered?Yes (insertion order)Yes (insertion order)Yes (Python 3.7+)NoYes (insertion order)
Mutable?YesNoYesYesYes
Allows duplicates?YesYesKeys: No, Values: YesNoYes
Lookup time complexityO(n) for searchO(n) for searchO(1) by keyO(1) membershipO(n) for search
Append / PushO(1) amortisedN/A — immutableO(1)O(1)O(1) both ends
Insert at frontO(n) — avoid!N/AN/AN/AO(1) — use deque!
Hashable (dict key)?NoYes (if contents hashable)NoNoNo
Best real-world useOrdered, growable collectionFixed record / DB rowLookup by name / keyDeduplication / membershipQueue / 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.

🔥
TheCodeForge Editorial Team Verified Author

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.

← PreviousPython OOP Interview QuestionsNext →Top 50 JavaScript Interview Q
Forged with 🔥 at TheCodeForge.io — Where Developers Are Forged