Deep Engineering
Intermediate·Published·3.11 · 3.12 · 3.13 · 3.14·30 MIN

Built-in collections: list, tuple, dict and set — what to use when

The question of whether to choose a list, a tuple, a dictionary or a set sounds like a matter of taste, but it is settled by three checks: do you need order, do you need repeats, and how will you search. A run answers with numbers: the check x in on a list of a thousand elements is 216 times slower than on a set, and on ten elements only 3.4 times (CPython 3.13.7, one machine); a dictionary keeps insertion order, while a set of strings prints differently under different PYTHONHASHSEED values; and a set of a thousand numbers is 4.1 times bigger than a list.

Full technical treatment

TL;DR

Python has four main built-in collections, and you choose between them by three questions: do you need order, do you need repeats, and how will you search. A list is ordered and mutable, with repeats. A tuple is the same, but immutable. A dictionary holds keys and values, and the keys are unique. A set holds unique elements without order.

Hence the main consequence: when there are many "is this element there" checks, they are worth doing on a set or a dictionary, not on a list. A list checks the elements one by one; a set finds the element by its hash — a number the object computes from its value (hash(x)). On average that is O(1): the time hardly depends on the size; in the worst case, when hashes coincide, it is O(n) (in detail — in the lesson "Complexity of operations"). In our run (CPython 3.13.7, one machine) x in on a list of a thousand elements was 216 times slower than on a set, on a hundred thousand — thirteen thousand times; on your machine the ratio has to be measured. The price is memory and hashability: a set of a thousand numbers is 4.1 times bigger than a list, and set elements and dictionary keys must be hashable — a list cannot be one. Dictionary values need not be: {"items": []} works.

Beyond that comes what separates understanding from having read about it. On ten elements the difference is only 3.4 times, and the list finds its first element as fast as the set does; and building a set from a list for a single check did not pay off — it took longer than one scan. Since Python 3.7 a dictionary keeps insertion order — a language guarantee — while a set promises no order: a set of five strings printed in three different orders under three different PYTHONHASHSEED values. And a queue built on a list is a mistake: with a hundred thousand elements list.pop(0) costs 299 times more than deque.popleft().

Where to start
Before this lesson it is enough to understand
  • how to create a list and a dictionary and loop over them with for;
  • what a mutable and an immutable object are;
  • that the expression x in y checks membership;
  • what a class and an instance are — the lesson "Classes and OOP": it explains why instances of ordinary classes are hashable;
  • the notation O(1) and O(n) — at least as "does not grow" and "grows with the size"; in detail — in the lesson "Complexity of operations".
You do not need to know in advance
  • hashability, frozenset, dict.fromkeys;
  • the order of a dictionary and of a set, PYTHONHASHSEED;
  • the cost of x in, list.pop(0) and collections.deque.

Base: four collections and three questions

collectionorderrepeatsmutablehow it is searched
listyesyesyesby index; by value — by scanning
tupleyesyesnoby index; by value — by scanning
dictinsertion orderkeys are uniqueyesby key, via the hash
setnonoyesby value, via the hash

A junior-level answer to "what to use when": a list for an ordered collection that changes; a tuple for an immutable record of a few fields; a dictionary when a value is looked up by key; a set when you need unique elements and a fast "is it there" check. That is correct, and it is usually followed by three follow-up questions: how much faster the check on a set is, why a list cannot be put into a set, and what about order. That is what the mechanisms below are about.

How a list differs from a tuple in memory and speed is a separate lesson, "List versus tuple". Here the choice is among all four.

Mechanism 1: repeats and uniqueness

language contractThe uniqueness of set elements and dictionary keys is the data model and the dictionary documentation; the output is a run of bench/collections/basics.py, section 1, identical on CPython 3.11–3.14.

Output of bench/collections/basics.py, section 1 (labels translated):

1) duplicates
   list(data)          -> [3, 1, 3, 2, 1]
   set(data)           -> {1, 2, 3}
   dict.fromkeys(data) -> {3: None, 1: None, 2: None}
   {'a': 1, 'a': 2}    -> {'a': 2}
   len(set(data)) == len(data) -> False

A list keeps everything put into it. A set keeps only distinct elements, and the order of the source list is lost. A dictionary keeps unique keys, and when a key is repeated the last value wins.

This output gives two working techniques:

  • "Are there duplicates" — len(set(data)) != len(data).
  • "Remove duplicates, keeping the order" — list(dict.fromkeys(data)). A set cannot do this: a set has no order (Mechanism 3), while a dictionary does.

Mechanism 2: hashability — who can be a key

language contractThe hashability requirement for dictionary keys and set elements is the dictionary documentation and the data model; the output is runs of bench/collections/hashable.py and bench/collections/basics.py, section 2, on CPython 3.11–3.14. The error text changed in 3.14 — see the version history.

Set elements and dictionary keys must be hashable; dictionary values need not be. A set and a dictionary find an element or a key by its hash, so it must have a hash, and the hash must not change; a dictionary value sits next to its key, nothing is looked up by it, and there is no reason to hash it:

Values that are not hashable, that is, values containing lists, dictionaries or other mutable types (that are compared by value rather than by object identity) may not be used as keys.

— Built-in types — dictionaries

A short complete example shows it. The full file bench/collections/hashable.py:

PYTHON
import sys
 
print("PY", sys.version.split()[0])
 
d = {"items": []}
d["items"].append(1)
print('{"items": []} after append:', d)
print('{(1, 2): "ok"}:', {(1, 2): "ok"})
 
for text, make in [('{[]: "x"}', lambda: {[]: "x"}), ("{[1, 2]}", lambda: {[1, 2]})]:
    try:
        make()
    except TypeError as e:
        print(text, "-> TypeError:", e)

Run it with python3.13 bench/collections/hashable.py; the output on CPython 3.13.7 (the same on 3.11 and 3.12), exactly as printed:

PY 3.13.7
{"items": []} after append: {'items': [1]}
{(1, 2): "ok"}: {(1, 2): 'ok'}
{[]: "x"} -> TypeError: unhashable type: 'list'
{[1, 2]} -> TypeError: unhashable type: 'list'

The dictionary with a list as its value was created and even changed in place; the tuple became a key. A list fits neither as a key nor as a set element. More on what is hashable — the output of bench/collections/basics.py, section 2 (labels translated):

2) hashability: what can go into a set and into dict keys
   {[1, 2]}            -> TypeError: unhashable type: 'list'
   {[1, 2]: 'x'}       -> TypeError: unhashable type: 'list'
   {(1, 2)}            -> no exception
   {(1, [2])}          -> TypeError: unhashable type: 'list'
   {frozenset({1, 2})} -> no exception
   {{1, 2}}            -> TypeError: unhashable type: 'set'

The line worth remembering is {(1, [2])}. A tuple is hashable only if everything in it is hashable: the hash of a tuple is computed from the hashes of its elements, and a list has none. That is why a key "of several fields" is a tuple of immutable values, and a set of sets is built from frozenset.

Why mutable objects that are compared by value — lists, dictionaries, sets — have no hash: a dictionary places a key by its hash and later looks it up by the same hash. If such a key changed after it was inserted, its hash would change, and the dictionary would look in the wrong place. A value has no such problem: it is not looked up by hash, so d["items"].append(1) above breaks nothing. Instances of ordinary classes are mutable but hashable: they are compared by identity, not by value — see the lesson "Classes and OOP". How this works inside is covered in the article on dictionaries.

Mechanism 3: order — a dictionary has it, a set does not

language contractDictionary insertion order is a language guarantee since 3.7; the absence of order in a set is the data model. The output is a run of bench/collections/basics.py, section 3; the orders under the same PYTHONHASHSEED matched on CPython 3.11–3.14.

Dictionaries preserve insertion order. Note that updating a key does not affect the order. Keys added after deletion are inserted at the end.

— Built-in types — dictionaries

A set has no such guarantee. The order in which it prints depends on the hashes of its elements, and the hash of a string differs from run to run:

By default, the __hash__() values of str and bytes objects are "salted" with an unpredictable random value. Although they remain constant within an individual Python process, they are not predictable between repeated invocations of Python.

— The data model — __hash__

That random "seed" can be fixed with the PYTHONHASHSEED environment variable. The script ran itself three times with different values — the output of bench/collections/basics.py, section 3. The script uses Russian fruit names as its strings; below they and the labels are translated, so this exact text will not appear in the run, and the orders shown are those of the original strings:

3) order
   dict in insertion order: ['melon', 'apple', 'pear']
   removed and inserted again — the key is at the end: ['melon', 'pear', 'apple']
   a set of strings under different PYTHONHASHSEED values:
   PYTHONHASHSEED=1: ['melon', 'pear', 'plum', 'apple', 'cherry']
   PYTHONHASHSEED=2: ['cherry', 'apple', 'melon', 'plum', 'pear']
   PYTHONHASHSEED=3: ['melon', 'plum', 'apple', 'pear', 'cherry']
   distinct orders out of three runs: 3
   a set of integers {5, 1, 3}: {1, 3, 5} — the order comes from the hash, not from insertion
   hash(1), hash(3), hash(5): 1 3 5 — the hash of a small integer equals the integer itself
   a set of integers {8, 1}: {8, 1} — and not always in ascending order

The last lines are a trap in reverse. A set of small integers often prints "in ascending order", because the hash of a small integer equals the integer itself. But that is a coincidence of the implementation, not sorting: {8, 1} printed as {8, 1}, and code that relies on the order will break on strings. If you need order, use a list, a dictionary or sorted(...).

Mechanism 4: the cost of a lookup — where the difference is huge and where there is none

measured observationbench/collections/cost.py, section 1: medians of nine alternating rounds, CPython 3.13.7, one machine (2 cores); 3.14.7 is in the record cost-3.14.txt, with ratios of the same order. The ratios are an observation of this configuration: they depend on the size and kind of data, the version and the machine; measure them on your own. The cost of building a set is bench/collections/build.py, 14.10.2026, linux/x86_64.

The documentation names "fast membership testing" as the first use of sets. How fast is a matter of measurement. The element sought is the last one, which is the worst case for a list:

sizelistsetdict (keys)list / set
10119.5 ns35.2 ns35.4 ns3.4
1,0009,690.0 ns44.9 ns48.4 ns216.0
100,0001,052,432.4 ns79.4 ns56.7 ns13,250.6

A list checks the elements one by one, so its time grows with the size. A set and a dictionary compute the hash and go to the right place in the table; when the hashes are well spread, as with these integers, only a few slots need checking — so their time grows by a few times, not by thousands of times like the list's. A tuple behaves like a list: it is a sequence too.

But O(1) for a set and a dictionary is an average estimate, not a promise of one check. If many keys share one hash, a lookup compares them one by one, and the worst case is O(n): in the lesson "Complexity of operations" a lookup in a set of a hundred objects with the same hash called __eq__ 105 times, not once. And a single check is not free either: it costs computing the hash and a call to __eq__, and for a long string both go over its characters.

The numbers also include the for loop the check ran in: an empty loop costs 18.7 ns per step — the "empty loop" row in the run record. At large sizes that is negligible, but at ten elements it is half of the set's time, so 3.4 times is a lower bound: the checks themselves differ more.

But the rule "always a set" does not work for small collections. On ten elements the difference is only 3.4 times, and if the element sought is the first one, the list finds it in 33.7 ns — as fast as the set. And building a set for a single check does not pay: building hashes every element and places it in the table. The measurement above does not see this — there the collections are ready before the clock starts — so building was measured separately. The output of bench/collections/build.py (CPython 3.13.7; labels translated):

building a set for the checks: ns per scenario, medians of 9 rounds
      size    checks     by list     set(lst) + checks
        10         1        98.8                 211.1
        10       100      8499.6                1962.7
      1000         1      4638.7                7926.6
      1000       100    596511.4               10674.0

For a single check, building the set lost to the scan both at ten and at a thousand elements; for a hundred checks it won. A set pays off when there are many checks; if it already exists, it is faster even at ten elements. Where the break-even point lies on your data is a matter of measurement, not of a rule.

Mechanism 5: a queue is not a list

measured observationbench/collections/cost.py, section 2: 50,000 removals from a collection of 100,000 elements, building the collection is not part of the measurement; medians of nine rounds, CPython 3.13.7.

Output of bench/collections/cost.py, section 2 (labels translated):

2) removal from the front: 50,000 removals from a collection of 100,000, ns per removal
   (building the collection is not part of the measurement)
   form                             min    median       max
   list.pop(0)                  11920.9   12662.3   13358.6
   deque.popleft()                 41.6      42.4      47.3
   list.pop() from the end         42.5      43.7      46.3
   list.pop(0) / deque.popleft() by medians: 299

A list stores its elements contiguously, and removing the first one shifts all the others by one position — the longer the list, the more it costs. Removing from the end shifts nothing, so list.pop() is cheap. For a queue — "append to the end, take from the front" — the standard library has collections.deque, which its docstring describes as "a list-like sequence optimized for data accesses near its endpoints". In the measurement popleft() was 299 times cheaper than list.pop(0).

Deeper: what memory costs

measured observationbench/collections/basics.py, sections 4 and 6: sys.getsizeof of the container itself for 1000 keys, CPython 3.13.7; on 3.14.7 the tuple is 8 bytes bigger, section 6 is identical on 3.11–3.14. The entry layout is from the CPython 3.13.7 and 3.14.7 headers. The size is a property of the CPython implementation, not a language guarantee.

Output of bench/collections/basics.py, section 4 (labels translated):

4) memory: the container itself for 1000 integers, bytes (sys.getsizeof)
   list     8056
   tuple    8040
   set     32984
   dict    36952
   set / list: 4.1

sys.getsizeof counts the container itself, without its elements: the numbers are shared and do not count. A list and a tuple store one pointer per element — 8 bytes on a 64-bit build — plus a header. A set and a dictionary hold a hash table with a reserve of empty slots — without that reserve, lookup by hash would stop being fast — plus bookkeeping data. Hence 4.1 times more memory for the set: that is the price of a fast in. But 4.1 is the ratio for a thousand integers on this version, not a constant: it depends on the number and kind of elements and on the implementation.

In CPython a set and a dictionary are built differently. In a set each table slot is a pointer to the element plus its cached hash (setentry, Include/cpython/setobject.h:20–23 in CPython 3.13.7). A dictionary is an index table (dk_indices) plus a separate array of entries (dk_entries). A general entry stores the hash, the key and the value (PyDictKeyEntry, Include/internal/pycore_dict.h:76–81), while an entry of a dictionary whose keys are all strings stores only the key and the value (PyDictUnicodeEntry, lines 83–86): a string keeps its hash in itself (the hash field, Include/cpython/unicodeobject.h:101). It shows in memory — the output of bench/collections/basics.py, section 6, identical on 3.11–3.14 (labels translated):

6) memory and the kind of keys: 1000 keys, bytes (sys.getsizeof)
   dict, int keys      36952
   dict, str keys      26032
   set, int            32984
   set, str            32984

A dictionary with the same thousand keys, but strings, is 10,920 bytes smaller; a set's size does not depend on the kind of elements. The details are in the articles on how lists and dictionaries are built.

How to answer in an interview

Short answer: a list is an ordered mutable collection with repeats, a tuple is immutable, a dictionary maps key → value with unique keys, a set holds unique elements without order. Membership checks and lookups by value are done on a set or a dictionary: there it is a hash lookup — O(1) on average and O(n) in the worst case, when hashes coincide — in a list it is a scan. Only a hashable object can be a set element or a dictionary key; dictionary values can be anything. A dictionary keeps insertion order, a set does not.

That is enough for a correct answer. What follows is what you add if the interviewer digs.

If the interviewer digs deeper

On "how much faster is a set". "It depends on the size and on the data. In my run on CPython 3.13.7 x in on a list of a thousand elements was 216 times slower, on ten — 3.4 times, and the list finds its first element just as fast. Building a set for a single check is not worth it: in the measurement it took longer than one scan and paid off at a hundred checks. A set's O(1) is the average: with coinciding hashes a lookup degenerates into a scan. The exact ratios on another machine have to be measured."

On "why can't a list go into a set". "A set searches by hash, and the hash of a mutable object could change after insertion. That is why a list has no hash. A tuple is hashable only if everything in it is: {(1, [2])} fails. But a list can be a dictionary value — values are not looked up: {"items": []} works."

On "is a dictionary ordered". "Since 3.7 insertion order is a language guarantee. A key that is removed and inserted again goes to the end. A set has no order: a set of strings printed differently under different PYTHONHASHSEED values."

On "how to make a queue". "collections.deque. list.pop(0) shifts all the elements — in my run it cost 299 times more than popleft() on a hundred thousand."

Next they ask

Next they ask

How do you count how many times each element occurs?

Short answer

With a dictionary: counts[x] = counts.get(x, 0) + 1. Or with the ready-made collections.Counter(data) — a dictionary where a missing key gives zero, plus methods such as most_common(). For grouping, a similar building block is collections.defaultdict(list).

Next they ask

What is a frozenset and what is it for?

Short answer

An immutable set. Since it does not change, it has a hash — and it can be put into another set or used as a dictionary key. An ordinary set will not do for this: {{1, 2}} fails with unhashable type: 'set'.

Next they ask

How does a dictionary differ from a list of pairs?

Short answer

In lookup. In a list of pairs [(key, value), …] finding a value by key is a scan, like x in list. A dictionary finds a key by its hash — on average in a time that hardly depends on the size — and guarantees that keys are unique. A list of pairs is appropriate when keys repeat or are not hashable.

Common misconceptions

Claim

Membership checks should always be done on a set

Actually

If the set already exists and there are many checks — yes. But at ten elements the difference is only 3.4 times, and the list finds its first element in 33.7 ns, like the set. Building a set for a single check means hashing every element: in the measurement on CPython 3.13.7 that took longer than one scan both at ten and at a thousand elements.

Claim

Everything in a dictionary must be hashable

Actually

Only the keys — and set elements. A value can be a list or another dictionary: {"items": []} is created and changed in place, while {[]: "x"} fails with unhashable type: 'list'.

Claim

A set lookup is always one check

Actually

O(1) is an average estimate for well-spread hashes. If hashes coincide, a set compares elements one by one: in the lesson "Complexity of operations" a lookup among a hundred objects with one hash called __eq__ 105 times. The worst case is O(n).

Claim

A set sorts its elements

Actually

A set of small integers often, but not always, prints in ascending order — the hash of such a number equals the number itself; {8, 1} printed as {8, 1}. A set of strings printed in three different orders under different PYTHONHASHSEED values.

Claim

A dictionary does not keep order

Actually

Since Python 3.7 it keeps insertion order — a language guarantee, not an implementation detail. A key that is removed and inserted again goes to the end.

Claim

A tuple can always be a dictionary key

Actually

Only if everything in it is hashable. {(1, [2])} fails with unhashable type: 'list'.

Claim

A queue is conveniently built on a list with pop(0)

Actually

Removing the first element shifts all the others: on a hundred thousand, list.pop(0) cost 299 times more than deque.popleft().

Version history

VersionChangeWhat it means for code
3.7Dictionary insertion order becomes a language guarantee. In CPython 3.6 it was an implementation detail that could not be relied on, and before that there was no order at all.
3.11The reference point of the lesson. The observations of the script on 3.11, 3.12 and 3.13 match line by line, including the orders of the set under the same PYTHONHASHSEED. Entries of a dictionary with string keys without a hash field (PyDictUnicodeEntry) exist in all four versions checked.
3.14The error text for an unhashable object names the role: cannot use 'list' as a set element (unhashable type: 'list') and cannot use 'list' as a dict key (…); for {(1, [2])} — cannot use 'tuple' as a set element (unhashable type: 'list'). A tuple of 1000 elements became 8 bytes bigger — 8048 instead of 8040. The behaviour of the collections did not change.
3.15A new built-in immutable type, frozendict (PEP 814). The lesson did not run it: Python 3.15 was released on 9 October 2026, and our runs are 3.11–3.14. In the words of "What’s New In Python 3.15" (checked on 14.10.2026): "A frozendict is hashable as long as all of its keys and values are hashable" — unlike an ordinary dictionary, where only the keys have to be hashable.

Practice

Two problems. Answer first, then check against the real output: in both, the correct answer is taken from a run of the script rather than assigned.

Practice · predict the output

Duplicates are removed from a list of words in two ways, and the lengths are counted. What will this code print?
words = ["b", "a", "b", "c", "a"]
print(list(dict.fromkeys(words)))
print(sorted(set(words)))
print(len(set(words)), len(words))

Practice · estimate

A list and a set of a thousand numbers; the check is 999 in — the last element. How many times slower is the check on the list?
times

Check yourself

Question 1 of 5

You need to store unique user ids and often check whether an id is in the collection. What fits best?

How it was measured

The numbers of this lesson come from these scripts. Each one opens right from here — together with the record of its run: what it ran on, what came out and with what spread.

Observations without time — CPython 3.11.15, 3.12.3, 3.13.7 and 3.14.7; time measurements — 3.13.7 and 3.14.7 on one machine with two cores. The scripts bench/collections/hashable.py, bench/collections/build.py and section 6 of bench/collections/basics.py were added and run on 14.10.2026 on linux/x86_64. Contracts and quotes were checked on 14.10.2026. The lesson did not run Python 3.15 (released on 9 October 2026) — there is only a line about it in the version history.

Sources & further reading

6 SOURCES

  1. The data model — setsOfficial documentation. What a set is and what it is for: «These represent unordered, finite sets of unique, immutable objects». And the uses named outright: «Common uses for sets are fast membership testing, removing duplicates from a sequence, and computing mathematical operations such as intersection, union, difference, and symmetric difference». The text was checked against the built-in pydoc help (the pydoc_data.topics module) of CPython 3.13.7 and 3.14.7.https://docs.python.org/3.13/reference/datamodel.html#the-standard-type-hierarchy
  2. Built-in types — dictionariesOfficial documentation. The order guarantee: «Dictionaries preserve insertion order. Note that updating a key does not affect the order. Keys added after deletion are inserted at the end». And since which version: «Changed in version 3.7: Dictionary order is guaranteed to be insertion order. This behavior was an implementation detail of CPython from 3.6». And which keys are allowed: «Values that are not hashable, that is, values containing lists, dictionaries or other mutable types (that are compared by value rather than by object identity) may not be used as keys». Checked against pydoc_data.topics of 3.13.7 and 3.14.7.https://docs.python.org/3.13/library/stdtypes.html#mapping-types-dict
  3. The data model — __hash__Official documentation. Why the order of a set of strings changes from run to run: «By default, the __hash__() values of str and bytes objects are “salted” with an unpredictable random value. Although they remain constant within an individual Python process, they are not predictable between repeated invocations of Python». And what that means for sets: «Changing hash values affects the iteration order of sets. Python has never made guarantees about this ordering». Checked against pydoc_data.topics of 3.13.7 and 3.14.7.https://docs.python.org/3.13/reference/datamodel.html#object.__hash__
  4. collections.dequeOfficial documentation. The type's docstring in the interpreter: «A list-like sequence optimized for data accesses near its endpoints». Checked by calling collections.deque.__doc__ on 3.13.7 and 3.14.7.https://docs.python.org/3.13/library/collections.html#collections.deque
  5. Include/internal/pycore_dict.h and Include/cpython/setobject.hCPython source code. How table entries are laid out in CPython 3.13.7 (checked against the headers shipped with the interpreter, 14.10.2026). A general dict entry — pycore_dict.h:76–81, PyDictKeyEntry: «/* Cached hash code of me_key. */» and the fields me_hash, me_key, me_value. An entry of a dict with string keys — lines 83–86, PyDictUnicodeEntry: only me_key and me_value, «/* The key must be Unicode and have hash. */». The dict's hash table itself is the index array dk_indices, lines 167–180: «Actual hash table of dk_size entries. It holds indices in dk_entries». A set entry — setobject.h:20–23, setentry: key and hash, «/* Cached hash code of the key */». On 3.14.7 the same structures are at pycore_dict.h:80–90; PyDictUnicodeEntry is also in the 3.11 and 3.12 headers.https://github.com/python/cpython/blob/v3.13.7/Include/internal/pycore_dict.h
  6. What’s New In Python 3.15 — frozendictOfficial documentation. PEP 814: «A new immutable type, frozendict, is added to the builtins module. … A frozendict is hashable as long as all of its keys and values are hashable». Python 3.15 was released on 9 October 2026; the lesson did not run it — the quote was checked on 14.10.2026.https://docs.python.org/3.15/whatsnew/3.15.html