Skip to content

Tuples and sets

docs.scrimba.com

You know lists. Python has two more collection types that solve problems lists cannot. Tuples hold a fixed group of values that will never change. Sets hold only unique values and let you check membership instantly no matter how large the collection gets.

Python's collection toolkit has four types. Lists and dicts handle most general cases. Tuples and sets solve the specific ones: fixed records where immutability is an asset, and unique-value collections where O(1) membership testing is the priority.

Beyond list and dict, Python gives you tuple (a fixed-length sequence you cannot change after creating it) and set (an unordered collection of unique values, built on a hash table, the same lookup-by-content structure that makes dict fast). The split that drives every decision below: a tuple is hashable (its contents can be reduced to a single number so it can act as a dict key or set member), a list is not. Pick based on what you need the collection to do, not on which one you reach for out of habit.

Tuples

A tuple is an ordered group of values that cannot be changed after you create it. Parentheses define a tuple, but they are optional. The comma is what actually makes it a tuple. A single-item tuple requires a trailing comma.

Tuples are immutable sequences. The comma, not the parentheses, is what creates a tuple. Immutability makes them hashable when all their elements are too, which opens use cases that lists cannot fill: dict keys, set members, and fixed-structure records.

A tuple is a fixed-length sequence you cannot change once it exists. Because nothing inside it moves, Python can compute a single hash for it (a number derived from its contents) as long as every element is itself hashable, which is what lets a tuple serve as a dict key or set member where a list cannot. Indexing and slicing work exactly as on a list; there is no item assignment, so point[0] = 99 raises TypeError when that line runs. The single-item form (42,) needs the trailing comma. Without it, the parentheses only group and you get back the bare value 42.

python
point = (10, 20)
rgb = (255, 128, 0)
dimensions = (1920, 1080)
single = (42,)            # trailing comma required for a single-item tuple
also_tuple = 42, 99       # parentheses are optional; the comma makes it a tuple

Access by index works exactly like a list. Trying to change an item raises a TypeError:

Indexing, slicing, and negative indices all work identically to lists. Any attempt to assign via index raises TypeError; this is intentional, not a limitation.

Reading from a tuple, by index, negative index, or slice, behaves exactly like a list. Writing is what differs: there is no item assignment, so point[0] = 99 raises TypeError the moment that line runs. That is the whole point of choosing a tuple over a list. It is a value you and every later reader can trust not to change underneath you.

python
point = (10, 20)
point[0]    # 10
point[1]    # 20
point[-1]   # 20

point[0] = 99    # TypeError: 'tuple' object does not support item assignment
JunoTuples A tuple is an ordered group of values you can't change after you make it. The comma is what creates it, not the parentheses, which is why (42,) needs that lonely trailing comma. Try to reassign an item and you get a TypeError, and that locked-in feeling is exactly why you'd pick a tuple.
JunoTuples Tuples are immutable sequences, and the comma makes them, not the parentheses. That immutability is a feature: it makes a tuple hashable when its contents are, so it can be a dict key or set member where a list can't. Item assignment raises TypeError by design.
JunoTuples Reading a tuple matches a list; writing is what's missing, so point[0] = 99 raises TypeError at runtime. Lean on that: a tuple is the value you hand around when you need a guarantee nothing downstream will rewrite it. The single-item (42,) trap catches everyone once.

When to use a tuple

Use a tuple when you have a small group of related values that belong together and will not change. Coordinates (x, y), a colour (r, g, b), a name-score pair ("Alice", 87). The fixed structure signals to anyone reading the code that this group is treated as a single unit.

Tuples communicate fixed structure: a group of values where position carries meaning and the group is treated as a unit. Their hashability makes them valid as dictionary keys, which lists cannot be. The contract a tuple signals is: these values belong together and are not supposed to change.

Reach for a tuple when you have a record of fixed shape: a known number of fields where each position means something specific. Because a tuple is hashable (its contents reduce to a single number), it slots in anywhere a hashable value is required: a dict key, a set member, or the cached arguments behind functools.lru_cache, which remembers a function's results keyed on the arguments you called it with. The semantic signal also differs from a list. A tuple says "these fields belong together and each position has a meaning" (a coordinate, a colour), where a list says "a run of similar things whose length can change."

JunoWhen to use a tuple Reach for a tuple when you've got a fixed little group where each spot means something: a coordinate, an (r, g, b) colour, a name-and-score pair. Because tuples are hashable, you can even use one as a dict key. A list can't do that, which trips people up the first time they try.
JunoWhen to use a tuple A tuple signals fixed structure: position carries meaning and the group travels as one unit. That's the difference from a list, which says "a sequence whose length can vary." And since tuples are hashable, they work as dict keys where lists raise TypeError.
JunoWhen to use a tuple Tuple for a fixed-shape record, list for a variable-length run of similar things. The payoff is hashability: a tuple drops into a dict key, a set, or an lru_cache key, all the places a list can't go. Let the type carry that contract instead of a comment.
python
locations = {}
locations[(40, -74)] = "New York"   # tuple as a dict key, works
locations[[40, -74]] = "New York"   # list as a dict key, TypeError

Unpacking

Unpacking pulls values out of a tuple and assigns each to its own name in a single line. The number of names must match the number of values. Use * to capture any remaining items into a list.

Unpacking works on any iterable: tuples, lists, strings. The count of target names must match the iterable's length, unless a starred target catches a variable-length slice. Mismatch raises ValueError. Unpacking is the idiomatic way to consume multiple return values from a function.

Unpacking works on anything iterable (anything you can loop over), not only tuples, walking the right-hand side and binding each value to its target name in order. A starred target (*rest) soaks up the remaining items into a list, so the count no longer has to match exactly. A plain count mismatch raises ValueError at runtime. The form you reach for most is in a for header: for name, score in pairs unpacks each item as it loops, which reads far cleaner than indexing into each pair by hand.

JunoUnpacking Unpacking pulls each value out of a tuple or list and hands it its own name in one line, like x, y = point. The number of names has to match the number of values, unless you add a *rest to scoop up whatever's left over. It clicked for me the day I stopped writing point[0] and point[1] everywhere.
JunoUnpacking Unpacking assigns each value to its own name in a line, and a starred target like *rest catches a variable-length slice. Counts that don't line up raise ValueError. It's the clean way to take multiple return values from a function instead of indexing the result.
JunoUnpacking Unpacking runs on any iterable and binds left-to-right; *rest absorbs the slack, a bare mismatch raises ValueError. The spot it pays off most is the for header, for name, score in pairs, which beats reaching into each pair by index.
python
point = (10, 20)
x, y = point

print(x)   # 10
print(y)   # 20

first, *rest = [1, 2, 3, 4, 5]
# first = 1, rest = [2, 3, 4, 5]

head, *middle, tail = [1, 2, 3, 4, 5]
# head = 1, middle = [2, 3, 4], tail = 5

Named tuples

A named tuple is a tuple where each position has a name. Instead of remembering that point[0] is the x-coordinate, you write point.x. The values are still immutable; you get readable attribute names instead of numeric positions.

namedtuple generates a class that behaves exactly like a tuple but adds named attribute access. It is lighter than a full class, immutable, and self-documenting. Use it when a plain tuple's positional access would require a comment to be understood.

collections.namedtuple is a class factory (a function that builds and hands back a brand-new class for you to use). The class it returns is a tuple with named fields, so it costs the same memory as a plain tuple while reading far better. You get _asdict() (turn it into a dict), _replace() (make a copy with one field changed, since you can't mutate it), and _fields for free. When you need more, default values or type annotations, typing.NamedTuple covers the annotated case, and dataclasses.dataclass is the modern choice once you want methods or optional mutability.

JunoNamed tuples A named tuple gives every position a name, so you write point.x instead of remembering that point[0] is the x. It's still fully immutable and acts like a regular tuple in every other way. Your future self reading the code will thank you for the names.
JunoNamed tuplesnamedtuple builds a tuple-like class with named attribute access, immutable and self-documenting, for less weight than a full class. Reach for it the moment a plain tuple's positions would need a comment to explain. Past that, dataclass is the modern step up.
JunoNamed tuplesnamedtuple hands you a tuple subclass with named fields at the same memory cost, plus _replace() and _asdict() for free. Once you want defaults, methods, or real type hints, jump to typing.NamedTuple or dataclass rather than fighting the factory.

namedtuple lives in the standard library, so it needs an import first: from collections import namedtuple. Imports get full treatment in the Modules chapter.

python
from collections import namedtuple

Point = namedtuple("Point", ["x", "y"])
Player = namedtuple("Player", ["name", "score", "level"])

p = Point(10, 20)
p.x    # 10
p.y    # 20

alice = Player("Alice", 87, 5)
alice.name    # "Alice"
alice.score   # 87

Sets

A set is a collection of unique values with no guaranteed order. Adding the same value twice does nothing: a set keeps only one copy of each item. Use curly braces for a set with items, or set() to create an empty set.

set is an unordered collection that automatically rejects duplicates. Membership testing is O(1) regardless of size, which makes it the right tool whenever you need to check whether a value exists across a large collection. Note: {} creates an empty dict, not an empty set; use set() for that.

A set holds unique, hashable values and is built on a hash table, the same lookup-by-content structure as a dict. That buys you O(1) average membership, insertion, and deletion (the cost stays flat as the set grows, instead of climbing with its size). Two consequences to plan around: only hashable values can go in, so int, str, and tuple are fine but list, dict, and set are not, and iteration order follows internal hash positions, so never rely on it being stable. One syntax trap: {} is an empty dict, not an empty set; use set().

JunoSets A set keeps only unique values, so adding something that's already in there does nothing at all, no error, no duplicate. Build one with curly braces and items, but reach for set() for an empty one, because {} is secretly an empty dict. That last bit catches almost everyone.
JunoSets A set drops duplicates automatically and tests membership in O(1) no matter how big it gets, which is what makes it the right tool for "is this in here?" on a large collection. Watch the empty-set trap: {} is a dict, so use set().
JunoSets A set is a hash table of unique hashable values: O(1) membership, insert, and delete, at the cost that only hashable items go in and iteration order is never stable. Don't lean on that order, and remember {} is a dict, so reach for set() when you want an empty set.
python
tags = {"python", "beginner", "tutorial"}
numbers = {1, 2, 3, 4, 5}
empty = set()    # NOT {} (that's an empty dict)

Adding the same value twice does not change the set:

python
tags.add("python")   # tags is unchanged, "python" is already in it

When to use a set

Sets are the right tool for three things: removing duplicates from a list, checking quickly whether something is in a large collection, and comparing two groups to find what they share or differ on.

Three distinct use cases drive set usage: deduplication (automatic on insertion), O(1) membership testing (versus O(n) for list), and set algebra (|, &, -, ^). When the collection is large and you check membership frequently, the performance difference is substantial.

Three use cases come straight out of the hash table: uniqueness (duplicates rejected on insert), O(1) membership (the cost of in stays flat as the set grows), and set algebra (|, &, -, ^). The membership test is the one that pays off in real code: in on a set of 10,000 items is as fast as on a set of 10, where the same check on a list is O(n) and slows down linearly. When you find yourself doing repeated x in some_list against a large list, converting it to a set once is usually the fix.

JunoWhen to use a set Three jobs sets are great at: stripping duplicates out of a list, checking fast whether something is in a big collection, and comparing two groups to see what they share or differ on. If any of those is what you're doing, a set is probably the tool.
JunoWhen to use a set Three drivers: deduplication on insert, O(1) membership versus O(n) for a list, and set algebra with |, &, -, ^. The bigger the collection and the more often you check in, the more the set pulls ahead.
JunoWhen to use a set Uniqueness, O(1) in, and set algebra, all falling out of the hash table. The membership win is the practical one: repeated x in big_list is O(n) each time, so converting that list to a set once is the usual fix.
python
# Remove duplicates from a list
raw = ["cat", "dog", "cat", "bird", "dog", "cat"]
unique = list(set(raw))   # ["cat", "dog", "bird"] (order not guaranteed)
python
# Fast membership check
valid_codes = {"USD", "EUR", "GBP", "JPY"}
code = "EUR"

if code in valid_codes:    # instant lookup, even with thousands of codes
    print("Valid")

Set operations

Sets support the same operations you learned in maths: union (everything in either set), intersection (only what both sets share), and difference (what one has that the other does not). Python uses operator symbols for these, and each has a method equivalent.

Python's set operators mirror mathematical notation: | for union, & for intersection, - for difference, ^ for symmetric difference. Each operator has a method form (.union(), .intersection(), etc.) that also accepts any iterable, not only sets.

The operator forms (|, &, -, ^) require both sides to be sets and raise TypeError if either is something else, like a list. The method forms (.union(), .intersection(), and the rest) are looser: they accept any iterable and convert it for you, so a.union([1, 2]) works where a | [1, 2] fails. That difference is the one to remember when an operation blows up. There are also in-place forms (|=, &=, -=, ^=) that update the left set rather than returning a new one, the same as calling .update(), .intersection_update(), and so on.

JunoSet operations Four operations from maths class: | is union (in either), & is intersection (in both), - is difference (in one but not the other), and ^ is symmetric difference (in one but not both). Each one also has a spelled-out method like .union() if you prefer words to symbols.
JunoSet operations| union, & intersection, - difference, ^ symmetric difference. The method forms (.union() and friends) do the same but accept any iterable, not only a set, which the operators won't.
JunoSet operations Operators want both sides to be sets and raise TypeError otherwise; the method forms take any iterable, so a.union([1, 2]) works where a | [1, 2] doesn't. The in-place forms (|=, &=) update in place instead of returning a new set.
python
a = {1, 2, 3, 4}
b = {3, 4, 5, 6}

a | b    # {1, 2, 3, 4, 5, 6}   (union: everything in either)
a & b    # {3, 4}               (intersection: only in both)
a - b    # {1, 2}               (difference: in a but not b)
b - a    # {5, 6}               (difference the other way)
a ^ b    # {1, 2, 5, 6}        (symmetric difference: in one but not both)

These also have method forms: .union(), .intersection(), .difference(), .symmetric_difference().

Modifying sets

Sets are mutable. .add() adds one item. .update() adds several at once from any list or other iterable. .remove() deletes an item but raises an error if it is not there. .discard() deletes silently if the item exists and does nothing if it does not.

.add() is O(1) average. .update() accepts any iterable and is equivalent to calling .add() in a loop. .remove() raises KeyError on a miss, while .discard() is the safe choice when presence is uncertain. .pop() removes an arbitrary element, not the "last" one since sets have no order.

.add(x) inserts one item in O(1) average; .update(iterable) adds many and is the same as |=. The pair that bites people is .remove() versus .discard(): both delete an item, but .remove() raises KeyError when the item is absent while .discard() quietly does nothing. Reach for .discard() whenever presence is uncertain, so a missing item is not an exception you have to wrap. .pop() returns and removes some element, but which one is undefined: a set has no order, so never treat .pop() as taking the "last" item.

JunoModifying sets.add() puts in one item, .update() adds a whole batch from a list or other iterable. The pair to keep straight is removal: .remove() errors if the item isn't there, while .discard() shrugs and moves on. When you're not sure it's in the set, .discard() saves you a stray error.
JunoModifying sets.add() for one, .update() for many from any iterable. .remove() raises KeyError on a miss; .discard() is the safe version that ignores it. And .pop() pulls an arbitrary element, not a "last" one, since sets have no order.
JunoModifying sets.add() is O(1), .update() is |=. The trap is .remove() raising KeyError where .discard() stays quiet, so default to .discard() when presence is uncertain. .pop() returns an undefined element; never read it as the last one.
python
tags = {"python", "beginner"}

tags.add("tutorial")          # add one item
tags.update(["web", "api"])   # add multiple items from any iterable
tags.remove("beginner")       # remove, raises KeyError if not found
tags.discard("missing")       # remove, no error if not found
tags.pop()                    # remove and return an arbitrary item
tags.clear()                  # remove everything

Use .discard() when you are not sure whether the item exists.

Frozen sets

A frozen set is a set you cannot modify after creation. The main reason to use one: frozen sets are hashable, so they can be used as dictionary keys or stored inside other sets.

frozenset is the immutable counterpart to set. It supports all read operations and set algebra but not mutation. Its immutability makes it hashable, meaning it is valid as a dict key or as a member inside another set.

A frozenset is a set you cannot change after creating it. Because nothing inside it moves, it gets a stable hash and is itself hashable, so it can be a dict key or a member of another set, neither of which a plain set can do. Everything that reads or returns a new collection still works (membership, the set algebra operators), while the mutating methods (add, remove, and the rest) are gone. The case it fits cleanly: a constant lookup table that must not change while the program runs and may need to live inside a dict or set.

JunoFrozen sets A frozen set is a set you can't change once it's made. That locked-in state is what makes it hashable, so unlike a regular set you can use one as a dict key or tuck it inside another set. Reach for it when you have a fixed group of allowed values that should never shift.
JunoFrozen setsfrozenset is the immutable set: all the reads and set algebra, none of the mutation. Its immutability buys hashability, so it works as a dict key or a member of another set where a plain set raises. Good fit for a constant lookup table.
JunoFrozen setsfrozenset has a stable hash, so it goes where a set can't: dict keys, members of another set. Reads and set algebra stay, mutation methods are gone. The clean use is a constant lookup table that must not change at runtime.
python
valid_statuses = frozenset({"active", "paused", "deleted"})
valid_statuses.add("archived")    # AttributeError, frozenset is immutable

Choosing the right collection

Four types, each with a clear role. Ask what you need to do with the data and the right choice usually follows.

The choice between collection types is about what operations matter and what constraints your data has: mutability, ordering, duplicate handling, and lookup strategy.

The choice is part performance, part meaning. dict and set give O(1) average lookup through hashing, so checking membership stays fast as they grow. list and tuple give O(1) access by index but O(n) membership, where in slows down with size. The other axis is what the type signals: a tuple is a fixed record whose immutability buys hashability, where a list is a variable-length sequence. When you need a hashable collection to nest inside a dict or set, tuple and frozenset are the two built-in types that can do it.

JunoChoosing the right collection Four types, four clear jobs: lists for ordered things you'll change, tuples for fixed records, sets for unique values and fast "is it in here?" checks, dicts for looking things up by name. Ask what you need to do with the data and the right one usually falls out.
JunoChoosing the right collection Pick on what the data needs: mutability, order, whether duplicates matter, and how you look things up. List for an ordered sequence you mutate, tuple for a fixed record, set for unique values and O(1) membership, dict for key-value lookup.
JunoChoosing the right collection Half performance, half meaning: dict and set give O(1) lookup, list and tuple give O(1) by index but O(n) membership. A tuple also signals "fixed record" and buys hashability. When you need a hashable collection to nest, tuple and frozenset are your two options.
listtuplesetdict
OrderedYesYesNoYes (insertion order)
MutableYesNoYesYes
DuplicatesYesYesNoNo (keys)
Access byIndexIndexn/aKey
Use whenOrdered, changeable sequenceFixed recordUnique values, fast membershipKey-value lookup

A quick decision rule:

  • Need to look something up by name? → dict
  • Need an ordered collection you will modify? → list
  • Have a fixed group of related values? → tuple
  • Need unique values or fast membership tests? → set

In practice

Using tuples to store fixed records and a set to track unique values:

python
home = (51.5074, -0.1278)   # latitude, longitude
office = (51.5155, -0.0922)

home_lat, home_lon = home
print(f"Home: {home_lat}, {home_lon}")

# Track unique visitors with a set
visitors = set()
visitors.add("alice")
visitors.add("bob")
visitors.add("alice")    # already in set, silently ignored
visitors.add("carol")

print(f"Unique visitors: {len(visitors)}")
print(f"alice visited: {'alice' in visitors}")
print(f"dave visited:  {'dave' in visitors}")

Using sets to track what has already been processed and compute the remaining work:

python
already_processed = {"report_jan.csv", "report_feb.csv"}
all_files = {"report_jan.csv", "report_feb.csv", "report_mar.csv", "report_apr.csv"}

to_process = all_files - already_processed
print(f"Files to process: {sorted(to_process)}")

for filename in sorted(to_process):
    print(f"Processing {filename}...")
    already_processed.add(filename)

print(f"Done. Total processed: {len(already_processed)}")

Using frozenset for constant lookup tables and demonstrating O(1) membership testing with set algebra:

python
ALLOWED_METHODS = frozenset({"GET", "POST", "PUT", "PATCH", "DELETE"})
SAFE_METHODS = frozenset({"GET", "HEAD", "OPTIONS"})

# Set algebra on frozensets returns a regular set
unsafe_allowed = ALLOWED_METHODS - SAFE_METHODS
print(f"Non-safe allowed methods: {unsafe_allowed}")

# frozenset is hashable, so it can be stored in a set (a plain set cannot)
method_groups = {
    frozenset({"GET", "HEAD", "OPTIONS"}),
    frozenset({"POST", "PUT", "PATCH"}),
    frozenset({"DELETE"}),
}
print(f"Method groups: {len(method_groups)}")

method = "POST"
print(f"Allowed: {method in ALLOWED_METHODS}")
print(f"Safe:    {method in SAFE_METHODS}")

frozenset carries O(1) lookup and can be stored anywhere a hashable type is required. Set algebra on two frozenset objects returns a plain set; wrap the result in frozenset() to keep it immutable.