Collections Selection
Choosing the right built-in container -- list, tuple, set, frozenset, or dict -- is a design decision driven by ordering, mutability, uniqueness, and lookup needs, not habit.
Python offers several general-purpose built-in containers, and picking the right one for a job is a recurring design decision. This topic is not about how each container works internally (covered on their own pages) but about the criteria for choosing between them: does order matter, must elements be unique, will the collection be mutated, do you need fast lookup by key or position, and how should the data be represented conceptually.
The Four Questions to Ask
Before reaching for a container, answer four questions about the data: (1) Does order matter? (2) Must the collection change after creation? (3) Must elements be unique? (4) Do you need to look things up by key, or just iterate/index by position?
Question list tuple set frozenset dict
Preserves order? yes yes no no yes (keys, since 3.7)
Mutable? yes no yes no yes
Enforces uniqueness? no no yes yes keys: yes
Fast key/value lookup? no no no no yes (O(1) avg)
Hashable (usable as dict key)? no yes* no yes no
* a tuple is hashable only if all its elements are hashableList versus Tuple
Both preserve order and allow duplicates. The deciding factor is mutability and semantic intent: a list represents a homogeneous collection that will grow, shrink, or be reordered; a tuple represents a fixed-size, often heterogeneous record whose fields won't change after creation.
A secondary but real reason to prefer a tuple: it's hashable (if its elements are), so it can be a dict key or set element, while a list never can.
# list: an evolving collection of similar items
scores: list[int] = [88, 92, 79]
scores.append(95)
# tuple: a fixed structural record
point: tuple[int, int] = (3, 4) # x, y -- won't grow or shrink
db_row: tuple[int, str, float] = (1, "Ana", 30000.0)Set versus Frozenset
Both enforce uniqueness and drop ordering guarantees, giving fast average O(1) membership testing. The choice mirrors list vs tuple: set for a collection of unique items that will change; frozenset when that uniqueness must be locked down, especially to use the collection itself as a hashable value.
active_users: set[str] = {"ana", "bo"} # will be updated as users log in/out
active_users.add("cy")
PERMISSIONS: frozenset[str] = frozenset({"read", "write"}) # fixed, safe as a dict key
role_map = {frozenset({"read"}): "viewer", PERMISSIONS: "editor"}Dict: When Lookup by Key Matters
A dict is the right choice whenever data is naturally accessed by a meaningful key rather than by position -- configuration settings, records keyed by ID, counts keyed by category. Its combination of insertion-order preservation and O(1) average lookup makes it strictly more capable than a list of pairs for this purpose.
# awkward: linear search through a list of pairs
inventory_list = [("apple", 10), ("banana", 5)]
qty = next(q for name, q in inventory_list if name == "apple") # O(n)
# natural: direct O(1) average lookup by key
inventory: dict[str, int] = {"apple": 10, "banana": 5}
qty = inventory["apple"]Ordering: When It Does and Doesn't Matter
list, tuple, and dict (by key) all preserve a meaningful order; set and frozenset do not, and should never be used where the sequence of elements matters. If you need both uniqueness and stable order, keep the data in a dict (using keys, with dict.fromkeys() as a common idiom) or a list plus a separate set for fast membership checks.
# dedupe while preserving first-seen order -- dict keys, not a set
items = ["b", "a", "b", "c", "a"]
unique_ordered = list(dict.fromkeys(items))
print(unique_ordered) # ['b', 'a', 'c']Mutability and Safety
Immutable containers (tuple, frozenset, and dict keys implicitly) are safer to share across functions or store as constants, because no code path can accidentally mutate them out from under you, and they're hashable when their contents are, so they can serve as dict keys or set elements themselves.
DEFAULT_TAGS: frozenset[str] = frozenset({"draft", "unreviewed"})
def process(tags: frozenset[str] = DEFAULT_TAGS) -> None:
... # DEFAULT_TAGS can never be mutated by a callerA Practical Decision Table
Need... Use
Ordered, changeable, allows duplicates list
Ordered, fixed-size record, won't change tuple
Unique items, will change, order irrelevant set
Unique items, fixed, must be hashable frozenset
Lookup by meaningful key, fast access dictWorked Example: Choosing Containers for One Program
Each container was chosen by asking the four questions: does it change, must it be unique, is order meaningful, and do we look it up by key -- not by habit.
# Coordinates: fixed pair, hashable -> tuple
Point = tuple[int, int]
# Visited cells during search: unique, changes as we explore -> set
visited: set[Point] = set()
# Grid dimensions, fixed after load -> tuple
GRID_SIZE: tuple[int, int] = (10, 10)
# Cell costs looked up by coordinate -> dict
costs: dict[Point, int] = {(0, 0): 0, (0, 1): 1}
# Valid move directions, fixed set of unique deltas, used as a constant -> frozenset
DIRECTIONS: frozenset[Point] = frozenset({(0, 1), (0, -1), (1, 0), (-1, 0)})Key Takeaways
Ask four questions before choosing a container: does order matter, must it be mutable, must elements be unique, and do you need lookup by key.
list vs tuple is fundamentally a mutability and intent decision, both preserving order and allowing duplicates.
set vs frozenset mirrors that same mutability decision, but for unique, unordered collections.
dict is the right tool whenever data is naturally addressed by a meaningful key, not by position.
Immutable containers (tuple, frozenset) are hashable when their contents are, and are safer as constants or dict keys.
def get_qty(name): for n, q in inventory_list: if n == name: return q # O(n) scan def get_qty(name): return inventory[name] # dict: O(1) average
Data addressed by a meaningful key belongs in a dict, not a list of pairs scanned linearly.
unique_ordered = list(set(items)) # order not preserved unique_ordered = list(dict.fromkeys(items)) # preserves first-seen order
A plain set has no ordering guarantee; dict.fromkeys() deduplicates while keeping insertion order, since dicts preserve key order.
CONFIG_DEFAULTS = [1, 2, 3] # mutable constant, risky to share
CONFIG_DEFAULTS = (1, 2, 3) # immutable, safe to share as a constant
A module-level 'constant' that's actually a mutable list can be silently changed by any code that touches it; a tuple prevents that.
You need a collection of unique user IDs that will be updated as users connect and disconnect. Which container fits, and why?
A set: it enforces uniqueness automatically and supports fast average O(1) add/remove/membership testing, and ordering doesn't matter for a pool of connected user IDs.
You're representing an RGB color as three fixed numbers that will be passed around and never changed. list or tuple?
A tuple: the size and meaning of each position (R, G, B) is fixed and shouldn't change, and immutability also makes it usable as a dict key if colors ever need to be looked up or deduplicated.
Why might you choose a dict over a list of (key, value) tuples for storing product prices by SKU?
A dict gives O(1) average lookup by SKU directly, while a list of pairs requires an O(n) linear scan to find a matching key -- and the dict version reads more directly as 'price for this SKU'.
When would you pick frozenset over set for a group of allowed HTTP methods like {'GET', 'POST'}?
When that group is a fixed constant that should never be mutated accidentally, and/or when it needs to be hashable -- for example used as a dict key or nested inside another set -- neither of which a plain set supports.
A function needs to deduplicate a list of tags while preserving their original order. Which container(s) would you combine, and how?
dict.fromkeys(tags) followed by list(...) -- using dict keys for both uniqueness and order preservation, then converting back to a list, since a plain set would drop the original ordering.
What single property distinguishes 'can be a dict key' from 'cannot be a dict key' among Python's built-in containers?
Hashability, which in turn requires immutability: tuple (if its elements are hashable) and frozenset qualify; list, set, and dict do not, because they're mutable and their hash would have to change if their contents did.
Why is it misleading to say 'always use a list' as a default choice for any collection?
Because a list doesn't enforce uniqueness, doesn't give fast key-based lookup, and is mutable (so it can't be a dict key or a safe shared constant) -- reaching for set, dict, or tuple when those properties are actually needed communicates intent and avoids real performance or correctness costs.
Given raw log entries as (user_id, event) tuples, write a function that returns, for each user, the set of distinct events they triggered.
def events_by_user(logs: list[tuple[str, str]]) -> dict[str, set[str]]: result: dict[str, set[str]] = {} for user_id, event in logs: result.setdefault(user_id, set()).add(event) return result logs = [("u1", "login"), ("u1", "click"), ("u2", "login"), ("u1", "login")] print(events_by_user(logs)) # {'u1': {'login', 'click'}, 'u2': {'login'}}
A dict keyed by user_id gives fast lookup per user, and a set as each value automatically deduplicates repeated events (like u1's two 'login' entries) -- both containers chosen for the property the data actually needs.
What does each line print or do?
a == list(c) is True only if set iteration happens to yield [1,2,3] in that order, which isn't guaranteed -- this line is fragile and shouldn't be relied on in real code. b == tuple(sorted(c)) is reliably True since sorted() imposes a defined order. hash(b) succeeds and prints some integer, since b is a tuple of hashable ints and is therefore hashable itself.
Write a function choosing an appropriate container to return the intersection of allowed permissions across a list of permission sets, as an immutable result safe to cache as a dict key.
def common_permissions(perm_sets: list[set[str]]) -> frozenset[str]: if not perm_sets: return frozenset() result = set(perm_sets[0]) for perms in perm_sets[1:]: result &= perms return frozenset(result) print(common_permissions([{"read", "write"}, {"read", "delete"}, {"read"}])) # frozenset({'read'})
Intermediate work uses a mutable set for efficient in-place intersection (&=), but the final return value is converted to frozenset -- appropriate because the result represents a fixed group of permissions that the caller may want to hash or cache as a dict key.
a = [1, 2, 3]
b = (1, 2, 3)
c = {1, 2, 3}
print(a == list(c))
print(b == tuple(sorted(c)))
print(hash(b))