The previous lesson ended with a question: why is looking up catalog["Faust"] practically instantaneous? The answer lives in a sibling structure of the dictionary: the set. A set is a collection with no guaranteed order, no duplicates and no associated values — just elements, like a dictionary's keys without their values. In exchange for giving up order and repetition, it offers two superpowers: checking membership at full speed and operating with the logic of set theory (union, intersection, difference), which at Papyrus will answer questions like "which members bought this month but not last month?" in a single line.

Contents

  1. Creating sets (and the empty {} trap)
  2. Uniqueness: duplicates vanish
  3. Adding and removing elements
  4. Lightning-fast membership: why in flies
  5. Set operations: union, intersection, differences
  6. Subsets and supersets
  7. frozenset: the immutable set
  8. Set comprehensions

Creating sets (and the empty {} trap)

genres = {"novel", "drama", "poetry"}          # literal: braces with bare elements
from_list = set(["novel", "drama", "novel"])   # set() converts iterables and DEDUPLICATES
print(from_list)                                # {'novel', 'drama'} → the duplicate is gone

letters = set("papyrus")    # {'p', 'a', 'y', 'r', 'u', 's'} → the repeated 'p' is out

empty = set()               # the ONLY way to create an empty set
trap = {}                   # this is an empty DICTIONARY, not a set!
print(type(empty), type(trap))   # <class 'set'> <class 'dict'>

The {} trap is historical: dictionaries arrived in Python first and kept the empty braces. Memorize it: empty set = set(), always.

Elements must be immutable (hashable), like a dict's keys: strings, numbers and tuples yes; lists and dictionaries no. And there is no indexing: genres[0] is a TypeError, because a set has no positions.

Uniqueness: duplicates vanish

Uniqueness is not an annoying restriction: it is the star feature. The most frequent real-world use case — removing duplicates from a list — is one line:

# Today's orders at Papyrus: Luis has ordered Don Quixote twice by mistake
orders = ["Don Quixote", "Hamlet", "Don Quixote", "Faust", "Hamlet"]

unique_titles = set(orders)
print(unique_titles)               # {'Faust', 'Hamlet', 'Don Quixote'} (order not guaranteed)
print(len(unique_titles))          # 3 distinct titles, even though there were 5 orders

# If you need a list back (e.g. sorted), convert again:
sorted_unique = sorted(set(orders))
print(sorted_unique)               # ['Don Quixote', 'Faust', 'Hamlet']

Careful: set() does not preserve the list's original order. If order of appearance matters, there is a dict idiom you will see in the exercises.

Adding and removing elements

Sets are mutable (an immutable version exists: frozenset, below):

genres = {"novel", "drama"}

genres.add("poetry")             # adds one element (if already there, nothing happens)
genres.add("drama")              # no effect and no error: it was already in
genres.update(["essay", "novel"])      # adds several at once (like extend on lists)

genres.remove("essay")           # removes; KeyError if it doesn't exist
genres.discard("science")        # removes if present; SILENCE if absent
element = genres.pop()           # takes out an ARBITRARY element (not "the last": there's no order)
genres.clear()                   # empties the set
Method If the element is absent...
remove(x) Raises KeyError
discard(x) Does nothing (the relaxed option)

Lightning-fast membership: why in flies

With a list, x in some_list forces Python to compare element by element, from the start: if the list has a million entries, the worst case means a million comparisons. With a set (or a dict's keys), Python computes the element's hash — a numeric fingerprint — and jumps straight to the internal slot where it would be if it existed. One check, whether the set holds ten elements or ten million.

members = {"LUIS-001", "MARTA-002", "PAU-003"}   # Papyrus member codes

def is_valid_member(code):
    """The papyrus_utils function, now backed by a set: immediate validation."""
    return code.strip().upper() in members

print(is_valid_member("luis-001"))   # True
print(is_valid_member("ANA-999"))    # False

This is also the answer to 04-03's question: catalog["Faust"] is instantaneous because a dictionary's keys use exactly the same hash mechanism. You do not need the formal complexity theory (it will come if you ever look it up as "O(1) vs O(n)"); keep the practical rule:

Lots of "is it there or not?" checks against the same collection → turn it into a set first.

Set operations: union, intersection, differences

Here sets shine in their own right. Each operation exists as an operator and as a method (the method accepts any iterable; the operator demands two sets):

Operation Operator Method Result
Union a | b a.union(b) Elements in a or in b (or both)
Intersection a & b a.intersection(b) Elements in a and in b
Difference a - b a.difference(b) In a but not in b
Symmetric difference a ^ b a.symmetric_difference(b) In a or in b, but not in both

With Papyrus data: Ana wants to analyze member loyalty by comparing June's and July's buyers:

bought_june = {"Luis", "Marta", "Pau", "Julia"}
bought_july = {"Luis", "Julia", "Omar"}

print(bought_june | bought_july)   # {'Luis','Marta','Pau','Julia','Omar'} → active customer base
print(bought_june & bought_july)   # {'Luis', 'Julia'} → the loyal ones: back month after month
print(bought_june - bought_july)   # {'Marta', 'Pau'} → bought in June but NOT July: win them back?
print(bought_july - bought_june)   # {'Omar'} → new customer in July
print(bought_june ^ bought_july)   # {'Marta','Pau','Omar'} → only one of the two months

Five business questions, five lines, zero loops. Difference is not commutative (a - b != b - a); union, intersection and symmetric difference are.

Another natural use: each book's genres as sets, to answer "which books share a genre?":

book_genres = {
    "The Odyssey": {"classic", "epic"},
    "Hamlet": {"classic", "drama", "tragedy"},
    "Faust": {"classic", "drama"},
}
shared = book_genres["Hamlet"] & book_genres["Faust"]
print(shared)     # {'classic', 'drama'}

Subsets and supersets

tragedies = {"Hamlet", "Faust"}
classics = {"The Odyssey", "Hamlet", "Don Quixote", "Faust"}

print(tragedies <= classics)          # True: tragedies is a SUBset of classics
print(tragedies.issubset(classics))   # equivalent with a method
print(classics >= tragedies)          # True: classics is a SUPERset
print(tragedies.isdisjoint({"The Iliad"}))  # True: they share no element

Useful for validations: "are all the titles in the order in the catalog?" is set(order) <= set(catalog) — and yes, set(catalog) on the 04-03 dict extracts its keys.

frozenset: the immutable set

The same relationship as tuple/list: a frozenset is a frozen set. It accepts no add() or remove(), and in exchange it is hashable — it can be a dictionary key or an element of another set:

CLASSICS_PACK = frozenset({"The Odyssey", "Hamlet"})   # the shop's fixed bundle offer

offers = {
    CLASSICS_PACK: 19.90,        # a frozenset as a dict key: legal
}
print(offers[frozenset({"Hamlet", "The Odyssey"})])   # 19.9 → order doesn't matter

With a regular set as a key you would get TypeError: unhashable type: 'set'. Brief and occasional use, but when you need it, nothing else takes its place.

Set comprehensions

The third comprehension in the family (list → dict → set). Syntax: braces without a colon:

orders = ["  hamlet ", "FAUST", "Hamlet", "don quixote"]

# Normalized, unique titles, in one go (normalization in depth comes in 04-05)
unique = {o.strip().lower() for o in orders}
print(unique)     # {'hamlet', 'faust', 'don quixote'} → 4 orders, 3 real titles

# Over the canonical catalog from 04-03: titles with stock
catalog = {
    "The Odyssey": {"price": 12.50, "stock": 4},
    "Hamlet": {"price": 9.95, "stock": 6},
    "Don Quixote": {"price": 15.90, "stock": 8},
    "Faust": {"price": 21.00, "stock": 0},
}
available = {t for t, record in catalog.items() if record["stock"] > 0}
print(available)   # {'The Odyssey', 'Hamlet', 'Don Quixote'}
Comprehension Syntax Produces
List [x for x in it] List (ordered, with duplicates)
Dictionary {k: v for ...} Dict (key: value)
Set {x for x in it} Set (unique, unordered)

Common Mistakes and Tips

  • {} is not an empty set: it is a dict. The mistake gives no message — trap.add(x) will simply fail later with an AttributeError. Empty set = set().
  • Expecting order: sets guarantee no iteration order. If you print a set and it "comes out shuffled", it is not a bug. For display, sorted(my_set).
  • Indexing a set: s[0] raises TypeError. Without order there are no positions; if you need "the first one", you are missing a list.
  • Inserting mutable elements: {["a", "b"]} raises TypeError: unhashable type: 'list'. Convert to a tuple if you need grouping.
  • Confusing remove() with discard(): if absence is normal, discard(); if it is a bug, remove() (let it fail early). The same criterion as brackets vs get() on dicts.
  • Deduplicating with a set when order matters: set(some_list) loses the order of appearance. The idiom that preserves it: list(dict.fromkeys(some_list)) — dicts deduplicate keys and remember insertion order.
  • Tip: if you catch yourself writing nested loops to compare two lists ("the ones here that aren't there"), it was almost certainly a set operation.

Exercises

  1. Order audit. From the list orders = ["Hamlet", "Faust", "Hamlet", "Dracula", "Don Quixote", "Faust"], obtain: (a) the set of distinct titles ordered; (b) how many duplicate orders there were (total orders minus distinct titles); (c) using the canonical catalog from 04-03 and a set difference, which ordered titles are not in the catalog.
  2. Loyalty campaign. With june = {"Luis", "Marta", "Pau", "Julia"} and july = {"Luis", "Julia", "Omar"}, compute: the members to win back (bought in June but not in July), July's newcomers, and the loyal ones from both months. Then check with a subset operator whether all the loyal ones bought in June.
  3. Available genres. With the lesson's book_genres dict (The Odyssey, Hamlet, Faust) and the available set computed over the canonical catalog, build via operations and/or a comprehension the set of genres actually available today (the genres of the books with stock > 0). Hint: the union of several sets can be done with set().union(...).

Solutions

# Exercise 1
orders = ["Hamlet", "Faust", "Hamlet", "Dracula", "Don Quixote", "Faust"]
catalog = {
    "The Odyssey": {"price": 12.50, "stock": 4},
    "Hamlet": {"price": 9.95, "stock": 6},
    "Don Quixote": {"price": 15.90, "stock": 8},
    "Faust": {"price": 21.00, "stock": 0},
}

distinct = set(orders)                       # (a) {'Hamlet','Faust','Dracula','Don Quixote'}
duplicates = len(orders) - len(distinct)     # (b) 2
unknown = distinct - set(catalog)            # (c) {'Dracula'} → set(dict) takes the keys
print(distinct, duplicates, unknown)
# Exercise 2
june = {"Luis", "Marta", "Pau", "Julia"}
july = {"Luis", "Julia", "Omar"}

win_back = june - july           # {'Marta', 'Pau'}
newcomers = july - june          # {'Omar'}
loyal = june & july              # {'Luis', 'Julia'}
print(loyal <= june)             # True: every loyal member bought in June (by definition)

Tip: name the results with business vocabulary (win_back, loyal), not mathematical vocabulary (difference, intersection): the code explains itself.

# Exercise 3
book_genres = {
    "The Odyssey": {"classic", "epic"},
    "Hamlet": {"classic", "drama", "tragedy"},
    "Faust": {"classic", "drama"},
}
available = {t for t, record in catalog.items() if record["stock"] > 0}

genres_today = set().union(*(book_genres[t] for t in available if t in book_genres))
print(genres_today)   # {'classic', 'epic', 'drama', 'tragedy'} → Faust (sold out) contributes nothing

Notice: the * unpacks the sets as arguments to union() (the same * from 03-02), and the if t in book_genres protects against books with no genres on file — "Don Quixote" is available but has no entry, and without that filter there would be a KeyError.

Conclusion

Sets complete the trio of "curly brace" collections: you know how to create them (remembering that set() is the only spelling of the empty one), exploit automatic deduplication, and express business logic with union, intersection and differences in one line where you would previously have nested loops. You also understand why in is lightning-fast on sets and dicts — the magic of the hash — and you know frozenset for when immutability matters. At Papyrus they now deduplicate orders, validate members and compare months of sales. But in almost every example an actor has kept peeking through, one we have been using since module 1 without ever studying it seriously: the string, with its strip(), lower() and friends. The next lesson finally treats it as what it is — a sequence with superpowers of its own — and systematizes the normalization of user input that find_book() and is_valid_member() have been doing halfway.

Python Programming Course

Module 1: Introduction to Python

Module 2: Control Structures

Module 3: Functions and Modules

Module 4: Data Structures

Module 5: Object-Oriented Programming

Module 6: File Handling

Module 7: Error and Exception Handling

Module 8: Advanced Topics

Module 9: Testing and Debugging

Module 10: Web Development with Python

Module 11: Data Science with Python

Module 12: Final Project

© Copyright 2026. All rights reserved