Python Tuples, Sets, and Frozensets: Immutability, Uniqueness, Hashability, Set Operations, When to Use Each, and Memory Behavior
In the previous post, we covered lists — mutable, ordered, and the default collection. But lists are not always the right choice. What if your data should NEVER change? Use a tuple. What if you need unique values and lightning-fast lookups? Use a set. What if you need an immutable set that can be a dictionary key? Use a frozenset.
These three data structures solve specific problems that lists cannot. Understanding when to reach for each one — and why — is what separates a Python beginner from someone who writes efficient, intentional code.
Think of it this way: a list is a whiteboard — write, erase, rearrange freely. A tuple is a printed certificate — official, permanent, and tamper-proof. A set is a guest list at an exclusive club — no duplicates allowed, and the bouncer checks names instantly. A frozenset is a laminated guest list — no duplicates AND nobody can modify it.
Table of Contents
- Tuples
- Creating Tuples
- The Single-Element Tuple Trap
- Tuples Are Immutable
- Why Immutability Matters
- Tuple Indexing and Slicing
- Tuple Methods
- Tuple Unpacking
- Extended Unpacking with *
- Tuple as Function Return Values
- Tuple as Dictionary Keys
- Named Tuples
- Tuples in Memory — Why They Are Faster Than Lists
- Sets
- Creating Sets
- The Empty Set Trap
- Sets Are Unordered
- Sets Enforce Uniqueness
- Adding and Removing Elements
- Set Operations (Union, Intersection, Difference, Symmetric Difference)
- Set Operations as Methods
- Subset, Superset, and Disjoint
- Set Comprehensions
- Why Set Lookup Is O(1)
- When to Use a Set Instead of a List
- Frozensets
- Creating Frozensets
- When to Use Frozensets
- Hashability Explained
- What Makes an Object Hashable
- Why Mutable Objects Cannot Be Hashed
- Hashability and Dictionary Keys
- Tuples vs Lists vs Sets — Complete Comparison
- Real-World Use Cases
- Data Engineering Patterns
- Common Mistakes
- Interview Questions
- Wrapping Up
Tuples
Creating Tuples
# Parentheses ()
coordinates = (10.5, 20.3)
rgb = (255, 128, 0)
mixed = (1, "hello", 3.14, True)
# Without parentheses — Python recognizes tuples by commas
point = 10, 20, 30
print(type(point)) # <class 'tuple'>
# From other iterables
t = tuple([1, 2, 3]) # From list
t = tuple("hello") # ('h', 'e', 'l', 'l', 'o')
t = tuple(range(5)) # (0, 1, 2, 3, 4)
t = tuple({1, 2, 3}) # From set (order may vary)
The Single-Element Tuple Trap
# This is NOT a tuple — it is just 42 in parentheses
not_tuple = (42)
print(type(not_tuple)) # <class 'int'>
# THIS is a single-element tuple — the COMMA makes it a tuple
single = (42,)
print(type(single)) # <class 'tuple'>
print(len(single)) # 1
# Without parentheses — comma still works
also_single = 42,
print(type(also_single)) # <class 'tuple'>
# This trap catches everyone at least once. Remember:
# Parentheses do NOT make a tuple. The COMMA makes a tuple.
# (42) = integer 42
# (42,) = tuple containing 42
Real-life analogy: The comma is the real operator. Parentheses are just clothing. A person wearing a uniform (parentheses) is still a person (integer). But a person carrying a briefcase (comma) is now a business traveler (tuple) — the briefcase changes the classification, not the clothes.
Tuples Are Immutable
coords = (10, 20, 30)
# coords[0] = 99 # TypeError: 'tuple' object does not support item assignment
# coords.append(40) # AttributeError: 'tuple' object has no attribute 'append'
# del coords[0] # TypeError: 'tuple' object doesn't support item deletion
# You CANNOT change, add, or remove elements from a tuple
# To "modify" a tuple, you create a new one:
new_coords = coords + (40,)
print(new_coords) # (10, 20, 30, 40) — new tuple, original unchanged
print(coords) # (10, 20, 30)
# BUT: if a tuple CONTAINS a mutable object, that object can change
tricky = (1, 2, [3, 4])
tricky[2].append(5) # The list inside the tuple CAN be modified
print(tricky) # (1, 2, [3, 4, 5])
# tricky[2] = [99] # But you CANNOT reassign the slot itself — TypeError
The tuple is immutable — its SLOTS are fixed:
slot[0] → int 1 (cannot change what slot[0] points to)
slot[1] → int 2 (cannot change what slot[1] points to)
slot[2] → list [3,4] (cannot change what slot[2] points to)
BUT the list itself is mutable!
The tuple says "slot[2] always points to THIS list"
The list says "I can grow, shrink, or change my contents"
Why Immutability Matters
- Safety — nobody can accidentally modify data. Function returns, config values, and fixed records stay fixed.
- Hashability — immutable objects can be hashed, making tuples valid as dictionary keys and set members (lists cannot).
- Performance — Python can optimize immutable objects. Tuples use less memory and are created faster than lists.
- Intent — using a tuple signals “this data should not change.” It is documentation through data structure choice.
- Thread safety — immutable objects are inherently thread-safe. No locks needed for concurrent access.
Tuple Indexing and Slicing
# Same as lists — indexing, slicing, negative indices all work
colors = ("red", "green", "blue", "yellow", "purple")
print(colors[0]) # "red"
print(colors[-1]) # "purple"
print(colors[1:3]) # ("green", "blue") — returns a tuple, not a list!
print(colors[::-1]) # ("purple", "yellow", "blue", "green", "red")
print(len(colors)) # 5
print("red" in colors) # True
Tuple Methods
# Tuples have only TWO methods (lists have 11+)
nums = (1, 2, 3, 2, 1, 2, 4)
print(nums.count(2)) # 3 — how many times 2 appears
print(nums.index(3)) # 2 — index of first occurrence of 3
# That is it. No append, insert, remove, sort, reverse.
# Immutability means fewer methods — and that is the point.
Tuple Unpacking
# Assign tuple elements to individual variables
x, y, z = (10, 20, 30)
print(x) # 10
print(y) # 20
print(z) # 30
# Works without parentheses
name, age, city = "Naveen", 30, "Toronto"
# Swap variables — the most elegant Python trick
a, b = 1, 2
a, b = b, a
print(a, b) # 2 1
# Underscore _ for values you do not need
_, _, zip_code = ("123 Main St", "Toronto", "M5V 1A1")
print(zip_code) # "M5V 1A1"
# Unpack in a for loop
students = [("Alice", 88), ("Bob", 72), ("Charlie", 95)]
for name, score in students:
print(f"{name}: {score}")
Extended Unpacking with *
# * captures remaining elements into a list
first, *rest = (1, 2, 3, 4, 5)
print(first) # 1
print(rest) # [2, 3, 4, 5] — note: rest is a LIST, not tuple
*beginning, last = (1, 2, 3, 4, 5)
print(beginning) # [1, 2, 3, 4]
print(last) # 5
first, *middle, last = (1, 2, 3, 4, 5)
print(first) # 1
print(middle) # [2, 3, 4]
print(last) # 5
# Practical: separate header from data rows
header, *data_rows = [
("name", "age", "city"),
("Alice", 25, "Toronto"),
("Bob", 30, "London"),
("Charlie", 28, "Delhi"),
]
print(header) # ('name', 'age', 'city')
print(data_rows) # [('Alice', 25, 'Toronto'), ('Bob', 30, 'London'), ...]
Tuple as Function Return Values
# Functions often return multiple values as tuples
def get_stats(numbers):
return min(numbers), max(numbers), sum(numbers) / len(numbers)
low, high, avg = get_stats([10, 20, 30, 40, 50])
print(f"Min: {low}, Max: {high}, Avg: {avg}")
# Min: 10, Max: 50, Avg: 30.0
# divmod returns a tuple
quotient, remainder = divmod(17, 5)
print(f"17 / 5 = {quotient} remainder {remainder}") # 3 remainder 2
# enumerate returns tuples
for index, value in enumerate(["a", "b", "c"]):
print(index, value) # 0 a, 1 b, 2 c
Tuple as Dictionary Keys
# Lists CANNOT be dict keys (mutable → unhashable)
# Tuples CAN be dict keys (immutable → hashable)
# Use case: grid coordinates as keys
grid = {}
grid[(0, 0)] = "start"
grid[(3, 4)] = "treasure"
grid[(7, 2)] = "enemy"
print(grid[(3, 4)]) # "treasure"
# Use case: compound keys
prices = {
("Toronto", "Economy"): 299,
("Toronto", "Business"): 899,
("London", "Economy"): 649,
("London", "Business"): 1899,
}
print(prices[("Toronto", "Business")]) # 899
# This is impossible with lists:
# prices[["Toronto", "Economy"]] = 299 # TypeError: unhashable type: 'list'
Named Tuples
from collections import namedtuple
# Create a "class-like" tuple with named fields
Point = namedtuple("Point", ["x", "y", "z"])
Customer = namedtuple("Customer", "name age city") # String syntax also works
p = Point(10, 20, 30)
print(p.x) # 10 — access by name (readable)
print(p[0]) # 10 — access by index (still works)
print(p) # Point(x=10, y=20, z=30)
c = Customer("Naveen", 30, "Toronto")
print(c.name) # "Naveen"
print(c.city) # "Toronto"
# Named tuples are still immutable
# c.age = 31 # AttributeError
# Convert to dict
print(c._asdict()) # {'name': 'Naveen', 'age': 30, 'city': 'Toronto'}
# Create a modified copy
c2 = c._replace(age=31, city="Vancouver")
print(c2) # Customer(name='Naveen', age=31, city='Vancouver')
print(c) # Customer(name='Naveen', age=30, city='Toronto') — original unchanged
Real-life analogy: A regular tuple is a row of data in a spreadsheet with only column numbers (A, B, C). A named tuple is the same row but with column headers (Name, Age, City). The data is the same — named tuples just make it readable.
Tuples in Memory — Why They Are Faster Than Lists
import sys
# Tuples use LESS memory than lists
my_list = [1, 2, 3, 4, 5]
my_tuple = (1, 2, 3, 4, 5)
print(sys.getsizeof(my_list)) # 104 bytes
print(sys.getsizeof(my_tuple)) # 80 bytes — 23% smaller!
# Why?
# Lists need extra space for:
# - Dynamic resizing (over-allocated array for future appends)
# - Pointer to the resize mechanism
# Tuples are fixed-size — no over-allocation, no resize machinery
# Tuples are also FASTER to create
import timeit
print(timeit.timeit("(1, 2, 3, 4, 5)", number=10_000_000)) # ~0.15 sec
print(timeit.timeit("[1, 2, 3, 4, 5]", number=10_000_000)) # ~0.45 sec
# Tuples are ~3x faster to create because Python can cache small tuples
Sets
Creating Sets
# Curly braces {} with values
colors = {"red", "green", "blue"}
numbers = {1, 2, 3, 4, 5}
# From a list (deduplicates automatically)
names = ["Alice", "Bob", "Alice", "Charlie", "Bob"]
unique_names = set(names)
print(unique_names) # {'Alice', 'Bob', 'Charlie'} — duplicates removed
# From a string (each character becomes an element)
chars = set("hello")
print(chars) # {'h', 'e', 'l', 'o'} — unique characters only
The Empty Set Trap
# {} creates an empty DICT, not a set!
empty = {}
print(type(empty)) # <class 'dict'> — NOT a set!
# Use set() for an empty set
empty_set = set()
print(type(empty_set)) # <class 'set'> — correct!
# Why? {} was used for dicts first (Python 2). Sets came later.
# Python kept {} for dicts to avoid breaking existing code.
Sets Are Unordered
# Sets have NO index, NO order, NO slicing
colors = {"red", "green", "blue"}
# print(colors[0]) # TypeError: 'set' object is not subscriptable
# colors[:2] # TypeError
# The "order" you see when printing is an implementation detail
# It can change between runs and Python versions
# NEVER rely on set order
# If you need unique + ordered: use dict.fromkeys()
ordered_unique = list(dict.fromkeys(["banana", "apple", "banana", "cherry"]))
print(ordered_unique) # ['banana', 'apple', 'cherry'] — unique, insertion order preserved
Sets Enforce Uniqueness
# Duplicates are silently dropped
nums = {1, 2, 2, 3, 3, 3, 4, 4, 4, 4}
print(nums) # {1, 2, 3, 4}
print(len(nums)) # 4
# Adding a duplicate does nothing
nums.add(2)
print(nums) # {1, 2, 3, 4} — unchanged, no error
# Deduplication is the most common use case for sets
emails = ["a@x.com", "b@x.com", "a@x.com", "c@x.com", "b@x.com"]
unique_emails = list(set(emails))
print(f"{len(emails)} emails → {len(unique_emails)} unique")
# 5 emails → 3 unique
Adding and Removing Elements
fruits = {"apple", "banana"}
# ADD
fruits.add("cherry") # Add one element
fruits.update(["date", "elderberry"]) # Add multiple (like list.extend)
fruits.update("fig") # Careful! Adds 'f', 'i', 'g' individually
print(fruits)
# REMOVE
fruits.remove("banana") # Remove — KeyError if not found
fruits.discard("grape") # Remove — NO error if not found (safer!)
popped = fruits.pop() # Remove and return an ARBITRARY element
print(popped) # Could be any element — sets are unordered!
fruits.clear() # Remove all elements
print(fruits) # set()
| Method | Not Found Behavior | Use When |
|---|---|---|
remove(x) | KeyError | Absence is a bug |
discard(x) | No error (silent) | Absence is okay |
pop() | KeyError if empty | Need any element (order does not matter) |
Set Operations (Union, Intersection, Difference, Symmetric Difference)
Set Operations Visualized:
A = {1, 2, 3, 4} B = {3, 4, 5, 6}
UNION (A | B): Everything in A or B or both
{1, 2, 3, 4, 5, 6}
INTERSECTION (A & B): Only what is in BOTH
{3, 4}
DIFFERENCE (A - B): In A but NOT in B
{1, 2}
SYMMETRIC DIFFERENCE (A ^ B): In one but NOT both
{1, 2, 5, 6}
a = {1, 2, 3, 4}
b = {3, 4, 5, 6}
# Operators
print(a | b) # {1, 2, 3, 4, 5, 6} — union
print(a & b) # {3, 4} — intersection
print(a - b) # {1, 2} — difference
print(a ^ b) # {1, 2, 5, 6} — symmetric difference
# Practical examples:
all_customers = {"Alice", "Bob", "Charlie", "Diana"}
premium = {"Bob", "Diana"}
newsletter = {"Alice", "Charlie", "Diana", "Eve"}
free_customers = all_customers - premium
print(free_customers) # {'Alice', 'Charlie'} — customers who are not premium
both = premium & newsletter
print(both) # {'Diana'} — premium customers who also get newsletter
everyone = all_customers | newsletter
print(everyone) # All people across both sets (union)
Set Operations as Methods
# Method versions — work with ANY iterable (operators need both to be sets)
a = {1, 2, 3}
# These work even if the argument is a list or tuple:
print(a.union([3, 4, 5])) # {1, 2, 3, 4, 5}
print(a.intersection([2, 3, 4])) # {2, 3}
print(a.difference([3, 4, 5])) # {1, 2}
print(a.symmetric_difference([3, 4])) # {1, 2, 4}
# In-place versions (modify the original set):
a.update([4, 5]) # a = a | {4, 5}
a.intersection_update([2, 3, 4, 5]) # a = a & {2, 3, 4, 5}
a.difference_update([4, 5]) # a = a - {4, 5}
Subset, Superset, and Disjoint
a = {1, 2, 3}
b = {1, 2, 3, 4, 5}
c = {7, 8, 9}
# Subset: is every element of a also in b?
print(a.issubset(b)) # True — all of a is inside b
print(a <= b) # True — operator version
# Superset: does b contain everything in a?
print(b.issuperset(a)) # True — b contains all of a
print(b >= a) # True — operator version
# Disjoint: do a and c share ANY elements?
print(a.isdisjoint(c)) # True — no overlap at all
print(a.isdisjoint(b)) # False — they share {1, 2, 3}
# Strict subset/superset (not equal)
print(a < b) # True — a is a PROPER subset (smaller)
print(a < a) # False — a is not a proper subset of itself
Set Comprehensions
# Same syntax as list comprehensions, but with curly braces {}
squares = {x ** 2 for x in range(10)}
print(squares) # {0, 1, 4, 9, 16, 25, 36, 49, 64, 81}
# With condition
even_squares = {x ** 2 for x in range(10) if x % 2 == 0}
print(even_squares) # {0, 4, 16, 36, 64}
# Practical: unique file extensions from a list of filenames
files = ["data.csv", "report.pdf", "sales.csv", "image.png", "log.csv"]
extensions = {f.split(".")[-1] for f in files}
print(extensions) # {'csv', 'pdf', 'png'}
Why Set Lookup Is O(1)
LIST lookup (O(n)):
"Is 'cherry' in this list?"
Check element 0: "apple" — no
Check element 1: "banana" — no
Check element 2: "cherry" — yes!
→ Had to check 3 elements. For 1 million items, check up to 1 million.
SET lookup (O(1)):
"Is 'cherry' in this set?"
hash("cherry") → 12345
Go directly to slot 12345 in the hash table
Check: "cherry" matches? Yes!
→ Checked 1 slot, regardless of set size. Same speed for 10 or 10 million items.
Sets use a HASH TABLE internally — same as dictionaries.
Each element is hashed to compute its slot in the table.
Lookup = compute hash + check one slot = constant time.
# Proof: set lookup is dramatically faster for large collections
import timeit
big_list = list(range(1_000_000))
big_set = set(range(1_000_000))
# Search for element near the end
list_time = timeit.timeit("999_999 in big_list", globals=globals(), number=1000)
set_time = timeit.timeit("999_999 in big_set", globals=globals(), number=1000)
print(f"List: {list_time:.4f} sec") # ~3.5 seconds
print(f"Set: {set_time:.4f} sec") # ~0.0001 seconds
# Set is ~35,000x faster for this lookup!
When to Use a Set Instead of a List
| Scenario | Use List | Use Set |
|---|---|---|
| Need ordered elements | Yes | No (unordered) |
| Need duplicates | Yes | No (unique only) |
| Need index access [i] | Yes | No |
Need fast in check | No (O(n)) | Yes (O(1)) |
| Need deduplication | No | Yes |
| Need union/intersection | No | Yes |
| Collecting items in order | Yes | No |
| Checking membership frequently | Convert to set | Yes |
# Rule of thumb:
# If you write "if x in my_list" inside a loop → convert to set first
# SLOW
valid_codes = ["A001", "A002", "A003", ..., "A999"] # 999 items
for order in orders: # 100,000 orders
if order.code in valid_codes: # O(n) × 100,000 iterations = slow!
process(order)
# FAST
valid_codes = {"A001", "A002", "A003", ..., "A999"} # Convert to set
for order in orders:
if order.code in valid_codes: # O(1) × 100,000 iterations = fast!
Frozensets
Creating Frozensets
# frozenset is an IMMUTABLE set
fs = frozenset([1, 2, 3, 4])
print(type(fs)) # <class 'frozenset'>
print(len(fs)) # 4
# All set QUERY operations work
print(3 in fs) # True
print(fs | {5, 6}) # frozenset({1, 2, 3, 4, 5, 6}) — returns new frozenset
# But MODIFICATION operations do NOT work
# fs.add(5) # AttributeError: 'frozenset' has no attribute 'add'
# fs.remove(1) # AttributeError
# fs.clear() # AttributeError
When to Use Frozensets
# Use case 1: Frozenset as a DICT KEY (regular sets cannot be dict keys)
permissions = {
frozenset(["read"]): "viewer",
frozenset(["read", "write"]): "editor",
frozenset(["read", "write", "admin"]): "administrator",
}
user_perms = frozenset(["read", "write"])
print(permissions[user_perms]) # "editor"
# Use case 2: Frozenset as a SET MEMBER (set of sets)
groups = {
frozenset({"Alice", "Bob"}),
frozenset({"Charlie", "Diana"}),
frozenset({"Alice", "Charlie"}),
}
print(frozenset({"Alice", "Bob"}) in groups) # True
# Use case 3: Immutable configuration that should never change
ALLOWED_EXTENSIONS = frozenset({".csv", ".parquet", ".json", ".avro"})
# Nobody can accidentally add or remove extensions at runtime
Hashability Explained
What Makes an Object Hashable
A hash is a fixed-size integer computed from an object’s value. Python uses hashes for dictionary keys and set membership. An object is hashable if it has a hash value that never changes during its lifetime.
# All immutable types are hashable
print(hash(42)) # 42
print(hash(3.14)) # 322818021289917443
print(hash("hello")) # -1556225729053804079
print(hash((1, 2, 3))) # 529344067295497451
print(hash(True)) # 1
print(hash(None)) # 5765337555368381535
print(hash(frozenset({1, 2}))) # -1834016341293975159
# Mutable types are NOT hashable
# hash([1, 2, 3]) # TypeError: unhashable type: 'list'
# hash({1, 2, 3}) # TypeError: unhashable type: 'set'
# hash({"a": 1}) # TypeError: unhashable type: 'dict'
Why Mutable Objects Cannot Be Hashed
Why can mutable objects not be hashed?
Imagine a list [1, 2, 3] is a dict key with hash value 529.
Python stores it in slot 529 of the hash table.
Now you modify the list: [1, 2, 3].append(4) → [1, 2, 3, 4]
The hash SHOULD change (because the value changed).
But the dict still has it in slot 529.
Looking up [1, 2, 3, 4] computes a NEW hash → goes to a DIFFERENT slot.
The key is lost — you can never find it again!
This is why Python forbids mutable dict keys and set members.
Immutable objects have stable hashes — they never move slots.
Hashability and Dictionary Keys
| Type | Hashable? | Dict Key? | Set Member? |
|---|---|---|---|
| int, float, complex | Yes | Yes | Yes |
| str | Yes | Yes | Yes |
| bool | Yes | Yes | Yes |
| None | Yes | Yes | Yes |
| tuple (of hashables) | Yes | Yes | Yes |
| frozenset | Yes | Yes | Yes |
| list | No | No | No |
| set | No | No | No |
| dict | No | No | No |
| tuple containing a list | No | No | No |
# Tuple of hashables = hashable
print(hash((1, 2, 3))) # Works
# Tuple containing a list = NOT hashable!
# hash((1, [2, 3])) # TypeError: unhashable type: 'list'
# Even one mutable element makes the entire tuple unhashable
Tuples vs Lists vs Sets — Complete Comparison
| Feature | List | Tuple | Set | Frozenset |
|---|---|---|---|---|
| Syntax | [1, 2] | (1, 2) | {1, 2} | frozenset({1, 2}) |
| Mutable? | Yes | No | Yes | No |
| Ordered? | Yes | Yes | No | No |
| Duplicates? | Yes | Yes | No | No |
| Indexing? | Yes | Yes | No | No |
| Hashable? | No | Yes* | No | Yes |
| Dict key? | No | Yes* | No | Yes |
| Lookup speed | O(n) | O(n) | O(1) | O(1) |
| Memory | More (resizable) | Less (fixed) | More (hash table) | More (hash table) |
| Creation speed | Slower | Faster (3x) | Slower | Slower |
* Tuples are hashable only if ALL elements inside are hashable.
Real-World Use Cases
Data Engineering Patterns
# 1. TUPLE: Function returns, fixed records, config values
def get_connection_info():
return ("server.database.windows.net", 1433, "mydb") # Never changes
host, port, db = get_connection_info()
# 2. SET: Deduplication, fast lookups, comparing datasets
old_customers = {"Alice", "Bob", "Charlie"}
new_customers = {"Charlie", "Diana", "Eve"}
churned = old_customers - new_customers # {"Alice", "Bob"} — lost customers
acquired = new_customers - old_customers # {"Diana", "Eve"} — new customers
retained = old_customers & new_customers # {"Charlie"} — still here
# 3. SET: Validating column names
expected_columns = {"customer_id", "name", "email", "city"}
actual_columns = set(df.columns)
missing = expected_columns - actual_columns
extra = actual_columns - expected_columns
if missing:
raise ValueError(f"Missing columns: {missing}")
if extra:
print(f"Warning: unexpected columns: {extra}")
# 4. FROZENSET: Immutable config that must not be modified at runtime
VALID_FILE_EXTENSIONS = frozenset({".csv", ".parquet", ".json", ".avro", ".orc"})
REQUIRED_COLUMNS = frozenset({"customer_id", "order_date", "amount"})
# 5. NAMED TUPLE: Structured records without full class overhead
from collections import namedtuple
AuditRecord = namedtuple("AuditRecord", "table_name rows_read rows_written status timestamp")
record = AuditRecord("customers", 10000, 9998, "Success", "2026-06-15 14:30:00")
print(f"Loaded {record.rows_written} rows to {record.table_name}")
Common Mistakes
- Forgetting the comma in single-element tuples —
(42)is an integer, not a tuple. Use(42,). This causes subtle bugs when you try to iterate over what you think is a tuple but is actually a number. - Using
{}for an empty set —{}creates an empty dict, not a set. Useset()for an empty set. - Expecting sets to maintain order — sets are unordered. If you need unique + ordered, use
list(dict.fromkeys(items)). - Using
remove()without checking —set.remove(x)raises KeyError if x is not in the set. Usediscard(x)for safe removal. - Using lists for frequent membership checks —
if x in my_listis O(n). Convert to a set first for O(1) lookups. This matters in loops over large datasets. - Trying to use a list as a dict key — lists are unhashable. Convert to a tuple first:
my_dict[tuple(my_list)] = value. - Modifying a mutable object inside a tuple — the tuple itself is immutable, but a list inside it can change. This surprises people who think “immutable” means “nothing can change.” The tuple’s slots are fixed; the objects those slots reference may not be.
Interview Questions
Q: What is the difference between a tuple and a list? A: Tuples are immutable (cannot add, remove, or change elements), use less memory, are faster to create, and can be used as dictionary keys and set members. Lists are mutable (can be modified in place) and have more methods. Use tuples for fixed data and lists for collections that change.
Q: Why is set lookup O(1) while list lookup is O(n)? A: Sets use a hash table internally. To check membership, Python computes the element’s hash and jumps directly to that slot — one step regardless of size. Lists must scan element by element from the start until a match is found, which takes up to N steps for N elements.
Q: What is hashability and why does it matter? A: A hashable object has a fixed-size integer hash value that never changes. Python uses hashes for dict keys and set members (to compute which slot to store/find them in). Mutable objects (list, set, dict) cannot be hashed because changing their value would change their hash, breaking the hash table lookup. Immutable objects (int, str, tuple, frozenset) can be hashed.
Q: When would you use a frozenset instead of a set? A: When you need set properties (uniqueness, O(1) lookup) but also need immutability — for example, using a set as a dictionary key, storing sets inside other sets, or creating constants that must not be modified at runtime (like allowed file extensions or required column names).
Q: What are named tuples and when would you use them?
A: Named tuples (from collections.namedtuple) are tuples with named fields — you access elements by name (point.x) instead of index (point[0]). Use them for lightweight data records that do not need the overhead of a full class — function return values, database rows, configuration records, audit entries.
Q: How would you find customers who churned between two time periods?
A: Use set difference. old_customers = set(df_last_month["id"]), new_customers = set(df_this_month["id"]). Churned = old_customers - new_customers. New = new_customers - old_customers. Retained = old_customers & new_customers. Sets make this a one-line O(n) operation instead of a nested loop.
Wrapping Up
Tuples, sets, and frozensets each solve a specific problem that lists cannot. Tuples give you immutability and safety — use them for fixed data, function returns, and dict keys. Sets give you uniqueness and blazing-fast lookups — use them for deduplication, membership testing, and comparing datasets. Frozensets give you immutable sets — use them as dict keys and for configuration constants.
The key takeaway: choose your data structure based on what you NEED, not what you are used to. If your code has if x in my_list inside a loop, switching to a set can make it 35,000x faster. That is not a micro-optimization — that is the difference between a pipeline that runs in seconds and one that runs for hours.
Previous in this series: Lists — Indexing, Slicing, Sorting, and Methods
Next in this series: Dictionaries — Nesting, Comprehensions, and DefaultDict
Naveen Vuppula is a Senior Data Engineering Consultant and app developer based in Ontario, Canada. He writes about Python, SQL, AWS, Azure, and everything data engineering at DriveDataScience.com.