A Python set is an unordered collection of unique, hashable elements. Sets shine in five real-world situations: removing duplicates from a sequence (set(items)), fast O(1) membership testing (x in my_set), finding common or unique elements between collections via the four set operations (|, &, -, ^), implementing permission and tag systems, and detecting what changed between two snapshots. The frozenset type adds immutability so you can use a set-shaped value as a dict key or as an element of another set. And dict_keys and dict_items objects (from any Python dict) are themselves set-like, supporting the same operations directly. This piece is one of 17 short explainers in our Python Concepts Explained reference.
Key Takeaways
Sets store unique, hashable, unordered elements. Duplicates are silently discarded; ordering is not preserved across operations.
Four operations cover the algebra:| union, & intersection, - difference, ^ symmetric difference. The augmented forms (|=, &=, etc.) mutate in place.
Membership testing is O(1). For collections larger than ~20 items where you'll check x in collection repeatedly, sets dramatically beat lists.
frozenset is the immutable version. Use it when you need a set as a dict key, a set element, or anywhere hashability matters.
Dict views are sets too.d1.keys() & d2.keys() returns the keys both dicts share; the full set-operator vocabulary works on dict_keys and dict_items without converting.
Same two sets, four operators, four different colored regions. Memorize the symbols and the rest is reading the diagram.
What a Set Is (and What It Isn't)
A Python set stores an unordered collection of unique, hashable elements. Three properties define it:
Unique: duplicates are silently dropped. {1, 1, 2, 3, 3} is just {1, 2, 3}.
Hashable elements only: every element must have a stable hash, which excludes lists, dicts, and other mutable types. Tuples of immutables and frozensets are fine.
Unordered: there's no first item, no slicing, no s[0]. Iteration order is an implementation detail and shouldn't be relied on.
What a set is NOT: a replacement for a list when you also need order, a key-value mapping (that's dict), or a way to count occurrences (that's collections.Counter). When in doubt, ask: do I care about the order, or just the membership? Sets answer the membership question.
Creating Sets
# Literal syntax (curly braces, but NOT empty)
s = {1, 2, 3}
# The set() constructor takes any iterable
s = set([1, 2, 2, 3]) # {1, 2, 3} duplicates dropped
s = set("hello") # {'h', 'e', 'l', 'o'}
# Empty set: must use set(), NOT {}
empty = set()
not_empty = {} # this is an empty DICT, not a set!
# Set comprehension
squares = {x*x for x in range(5)} # {0, 1, 4, 9, 16}
The empty-set trap catches everyone exactly once. {} is an empty dict (the syntax predates sets); for an empty set, call set() explicitly.
The Four Operations
The operator forms (|, &, -, ^) match the Venn diagrams above. The method forms accept any iterable, not just another set, which is sometimes more convenient.
A = {1, 2, 3}
B = {2, 3, 4}
A | B # {1, 2, 3, 4} union
A.union(B, [5, 6]) # {1, 2, 3, 4, 5, 6} method takes multiple iterables
A & B # {2, 3} intersection
A.intersection(B) # {2, 3}
A - B # {1} difference: in A, not in B
B - A # {4} NOT symmetric
A ^ B # {1, 4} symmetric difference: in exactly one
A.symmetric_difference(B)
The operator forms require both operands to be sets; the method forms accept any iterable. For ad-hoc set math, the operators read better; for processing dynamic data, the methods are more flexible.
Augmented (In-Place) Operations
The augmented forms mutate the left-hand set instead of building a new one.
A = {1, 2, 3}
A |= {4, 5} # A is now {1, 2, 3, 4, 5}
A = {1, 2, 3, 4, 5}
A &= {3, 4, 5, 6} # A is now {3, 4, 5}
A -= {3} # A is now {4, 5}
A ^= {5, 6} # A is now {4, 6}
The corresponding method names are update, intersection_update, difference_update, symmetric_difference_update. The augmented operators are usually shorter and clearer; reach for the method form when you want to update from a non-set iterable.
Membership Performance: O(1) vs O(n)
The single biggest reason to use a set: x in my_set is O(1) on average. x in my_list is O(n) because Python has to walk every item.
import timeit
big_list = list(range(100_000))
big_set = set(big_list)
# Worst-case lookup: the item is at the very end
timeit.timeit("99_999 in big_list", globals=globals(), number=10_000)
# ~6 seconds
timeit.timeit("99_999 in big_set", globals=globals(), number=10_000)
# ~0.001 seconds ← ~6,000x faster
The constant-factor cost of hashing makes sets slightly slower for very small collections (under ~10 items), but the break-even is reached almost immediately. For any collection larger than a few dozen items where you'll check membership repeatedly, the set is the right choice.
How the speedup works
Sets are implemented as hash tables (see Wikipedia: Hash table). Each element is placed into a bucket determined by its hash, so finding an element only requires computing its hash and checking one bucket. Lists store items in a flat array; finding one requires scanning from the start. The O(1) vs O(n) gap grows linearly with collection size; for a million items, the set is roughly 100,000 times faster on the worst-case lookup.
Five Real-World Use Cases
The patterns that come up constantly in production code.
The dict.fromkeys form is the canonical answer when order matters. For order-agnostic deduplication, set(items) is the shortest path.
2. Fast membership testing
VALID_EXTENSIONS = {".jpg", ".png", ".gif", ".webp"}
for filename in upload.list_files():
suffix = Path(filename).suffix.lower()
if suffix in VALID_EXTENSIONS: # O(1) per check
accept(filename)
Lookup tables, allow-lists, deny-lists, sentinel values: anywhere you'd write "is this one of?" with more than a few options, prefer a set.
3. Permission and tag systems
user_permissions = {"read", "write"}
required_permissions = {"read"}
admin_permissions = {"read", "write", "admin"}
# Does the user have what's needed?
required_permissions <= user_permissions # True (subset)
# Is the user missing anything?
required_permissions - user_permissions # set() (nothing missing)
# What admin powers does this user lack?
admin_permissions - user_permissions # {'admin'}
The subset operator (<=) reads as "is needed inside what we have". Cleaner than chains of and/or and orders of magnitude faster than nested loops for large permission sets.
4. Detecting what changed between snapshots
before = {"alice", "bob", "charlie"}
after = {"alice", "bob", "diana"}
added = after - before # {'diana'}
removed = before - after # {'charlie'}
unchanged = after & before # {'alice', 'bob'}
The classic three-way diff in three lines. Use it for database sync, config diffs, cache invalidation, file watchers, anything that needs "what's new, what's gone, what stayed".
Tag-based filtering, recommendation systems, search facets: set algebra is the right tool, and it's almost always faster than the equivalent list-based loops.
frozenset and Sets of Sets
A frozenset is the immutable version of a set. It supports every read-only operation (membership, union, intersection, etc.) but no mutations. The main reason to use one: hashability.
# Regular sets can't be dict keys or set elements
{1, 2, 3} in {{1, 2, 3}} # TypeError: unhashable type: 'set'
# Frozensets can
frozenset({1, 2, 3}) in {frozenset({1, 2, 3})} # True
# Use case: caching results by argument-set
cache = {}
def expensive(args):
key = frozenset(args)
if key not in cache:
cache[key] = compute(args)
return cache[key]
Other use cases: representing graph edges (as frozenset({a, b}) so the edge doesn't depend on which vertex you name first), storing constant lookup tables (immutability documents intent), and any "set of sets" data structure.
Rule: use a regular set when you'll mutate it. Use a frozenset when you need to hash it, use it as a dict key, or document immutability.
Dict Views Are Sets Too
The most underused feature of Python dicts: the views returned by .keys() and .items() behave as sets. You can intersect them, union them, and subtract them directly, without converting to a set first.
defaults = {"host": "localhost", "port": 8080, "ssl": False}
config = {"port": 9090, "ssl": True, "debug": True}
# Keys both dicts share
common = defaults.keys() & config.keys() # {'port', 'ssl'}
# Keys only in config (potential typos vs unknown overrides)
extra = config.keys() - defaults.keys() # {'debug'}
# Keys only in defaults (not overridden)
missing = defaults.keys() - config.keys() # {'host'}
This is the cleanest way to compute config diffs, validate required dict keys, and find shared keys between dicts. For the broader pattern of dict iteration, see our iterate dict explainer.
Why .values() doesn't support set operations: values aren't required to be unique or hashable. {"a": [1], "b": [1]}.values() contains a duplicate-by-equality value that's also unhashable. Set operations need both uniqueness and hashability.
Inside the Hash Table
Sets in CPython are implemented as open-addressed hash tables, the same machinery that backs dict minus the value slots. Three things happen on every operation:
Hash the element. Python calls hash(x) to get a 64-bit number. Stable hashes are why elements must be hashable: a value whose hash could change would lose its bucket and become unfindable.
Probe the bucket. The hash modulo the table size gives a bucket index. If the bucket is empty (lookup miss) or holds the equal element (lookup hit), return.
Resolve collisions. If the bucket holds a different element, walk to the next bucket via Python's perturbation sequence until either the element is found or an empty bucket signals "not present".
The result is O(1) average-case membership testing, with O(n) worst-case (rare in practice because hash quality is usually high). The same machinery makes set.add, set.remove, and set operations all O(1) per element. When you union or intersect, CPython iterates the smaller set and probes the larger; the asymptotic complexity is O(min(len(A), len(B))) plus the iteration over the result.
Set Comprehensions
The same shape as a list comprehension, with curly braces instead of square. The result is automatically deduplicated.
# Unique lengths of words in a sentence
{len(word) for word in "the quick brown fox".split()} # {3, 5}
# Unique first letters
{word[0] for word in "the quick brown fox".split()} # {'t', 'q', 'b', 'f'}
Useful whenever you want "the distinct X" of some computation; otherwise a list comprehension followed by set(...) is equivalent and sometimes reads better.
Set vs Dict: When Each Wins
Both are hash-table-backed. Both have O(1) membership. They're not interchangeable.
Question
Pick
"Is this present?"
set
"What value is associated with this key?"
dict
"How many times does each item appear?"
collections.Counter
"Common keys between these two collections?"
dict.keys() & other.keys()
"Set of unique items, no extra data?"
set
"Unique items but I also need an ordering?"
dict.fromkeys(items) (dict acts as ordered set)
The "dict as ordered set" pattern is the cleanest workaround for the lack of an ordered-set type in the standard library: dict.fromkeys(items) produces a dict whose keys preserve insertion order and answer membership in O(1), with the values being meaningless (default None). For most "ordered unique items" use cases, that's all you need.
Serialization Gotchas: Sets Aren't JSON
JSON has no set type. Python's json.dumps refuses to serialize a set directly; you have to convert to a list first.
import json
tags = {"python", "tutorial", "beginner"}
json.dumps(tags) # TypeError: Object of type set is not JSON serializable
json.dumps(list(tags)) # OK, but order is unstable
json.dumps(sorted(tags)) # OK, deterministic order
The same applies to many other serialization formats. pickle handles sets natively, so within-Python serialization round-trips work without conversion. For cross-language serialization (HTTP APIs, log files, message queues), convert to list (or better, sorted(set)) at the boundary.
Building a custom encoder
For nested data structures that contain sets at arbitrary depth, write a small custom encoder:
class SetEncoder(json.JSONEncoder):
def default(self, obj):
if isinstance(obj, (set, frozenset)):
return sorted(obj) # or list(obj) for raw order
return super().default(obj)
json.dumps({"tags": tags}, cls=SetEncoder)
# '{"tags": ["beginner", "python", "tutorial"]}'
The decoder side has no equivalent automatic conversion; if your JSON-loaded data should be a set, convert it explicitly after parsing.
Set Comparisons
Beyond the four operations, sets support four comparison operators that read as plain English.
A = {1, 2, 3}
B = {1, 2, 3, 4}
A <= B # True A is a subset of B
A < B # True A is a proper subset (subset AND not equal)
B >= A # True B is a superset of A
A.isdisjoint({5, 6}) # True no common elements
For "do these collections overlap?" use not A.isdisjoint(B) (or bool(A & B) if you also want the items). For "is this a subset?" use <=; for "strictly smaller" use <.
Common Mistakes
Five traps to watch for:Mistake 1: {} for an empty set. That's an empty dict. Use set() for an empty set.Mistake 2: relying on iteration order. Sets are unordered. The current iteration order is an implementation detail; future Python releases can change it. If you need order, use a list or a dict.Mistake 3: trying to add an unhashable element.my_set.add([1, 2]) raises TypeError. Convert the list to a tuple (if its contents are also hashable) or to a frozenset.Mistake 4: using a regular set as a dict key.TypeError: unhashable type: 'set'. Use frozenset.Mistake 5: list(set(items)) when you needed insertion order. Sets don't preserve order. For order-preserving deduplication, use list(dict.fromkeys(items)).
Frequently Asked Questions
What is a Python set used for?
Python sets store unordered collections of unique, hashable elements. The five most common uses: removing duplicates from a sequence (set(my_list)), fast O(1) membership testing (item in my_set), set algebra to find common or unique elements between collections (intersection, union, difference), permission and tag systems, and detecting what changed between two snapshots. Use a set whenever the questions you ask are "is this present?" or "what do these two collections have in common?"
How do I remove duplicates from a list in Python?
The fastest one-liner: list(set(items)). It converts the list to a set (which keeps only unique items), then back to a list. The catch: sets are unordered, so the result's order isn't guaranteed to match the original. To preserve insertion order, use dict.fromkeys: list(dict.fromkeys(items)). Both run in O(n); the dict version is slightly slower because of the dict-machinery overhead but is the canonical answer when order matters.
Are Python sets faster than lists for membership testing?
Yes, dramatically so for large collections. Set membership is O(1) on average because sets are hash tables. List membership is O(n) because Python has to walk every item until it finds a match. For 10,000 items, set lookup is roughly 1,000 times faster. The break-even is around 10 items; below that, the constant-factor cost of hashing exceeds the linear scan, so a list can be faster. For anything larger than a few dozen items where you'll check membership repeatedly, a set wins.
What is a frozenset in Python and when should I use one?
A frozenset is the immutable version of a set. It supports all read-only operations (membership, intersection, union, etc.) but no mutations (add, remove). The main use case is hashability: because frozensets are immutable, they can be elements of other sets and keys of dictionaries. Regular sets cannot. Reach for frozenset whenever you need a "set of sets" or want to use a set-shaped value as a dict key.
Can I use set operations on Python dict views?
Yes. dict_keys and dict_items objects behave like sets and support the full set-operator vocabulary. d1.keys() & d2.keys() returns the keys both dicts share; d1.keys() - d2.keys() returns the keys in d1 only. dict_values doesn't support set operations because values aren't required to be hashable or unique. This is one of the most useful and least-known features of Python dicts.
The Bottom Line: Membership and Algebra in One Type
Sets answer two questions Python's other built-ins answer slowly: "is this in here?" (O(1) instead of O(n)) and "what do these collections have in common?" (with four operators that read like algebra). Five real-world use cases (deduplication, fast lookup, permissions, change detection, tag matching) cover most of what you'll actually do with them. Reach for frozenset when you need a set as a dict key or set element. And remember that dict.keys() and dict.items() are already set-like, so you can do dict algebra without converting. With those rules, the third built-in container takes its proper place alongside lists and dicts. For the rest of the most-asked Python concept questions, browse the full Python Concepts Explained index.
Drill Set Operations Until They're Reflex
CodeGym's Python track turns sets and the rest of the container toolkit into muscle memory through 800+ hands-on tasks across 62 levels. The AI validator checks every submission in seconds; the AI mentor explains what broke when you get stuck. First level free; full plan on the pricing page.
Start learning Python (free) →
GO TO FULL VERSION