frozenset is the immutable, hashable cousin of set. It supports every read-only set operation you already know (membership, union, intersection, subset checks) but rejects anything that would mutate it. Because it can't change, Python can hash it, which means a frozenset can be used as a dictionary key or stored inside another set. This lesson covers when that matters and how to use it well.
frozenset Is and Why It ExistsA regular set is mutable. You can add, remove, discard, pop, and clear its elements, and you can update it in place with operators like |=. That mutability is convenient, but it has one big downside: a set is unhashable. Python can't use a mutable object as a dictionary key or as a member of another set, because its hash would change every time you mutated it, and the surrounding data structure would lose track of where it lived.
That error is the whole reason frozenset exists. A frozenset holds the same kind of unordered, unique elements that a set holds, but it's immutable. Once you've built one, you can't add to it, remove from it, or otherwise change its contents. Because it doesn't change, Python can give it a stable hash, so it works anywhere hashability is required.
Same elements, but the wrapping type is now frozenset, and Python is happy to use it as a key.
Cost: frozenset has the same average O(1) lookup and membership cost as set. Immutability is what you're paying for, not raw speed. Building a frozenset from an iterable is O(n), the same as building a set.
frozenset is a built-in type, just like set. The constructor takes any iterable and returns a frozen copy of its unique elements.
Duplicates are dropped during construction, exactly like a regular set. The order in the repr is hash-driven and shouldn't be relied on.
One thing to keep in mind: there is no literal syntax for frozenset. You can write {1, 2, 3} for a set and {"a": 1} for a dict, but there's no equivalent for frozenset. You always go through the constructor.
{} always builds a dict (the empty dict literal). set() builds an empty set. frozenset() builds an empty frozenset. No surprises once you know the rule, but new readers sometimes expect a literal that doesn't exist.
Every read-only set operation works the same way on a frozenset. Membership checks, length, iteration, union, intersection, difference, symmetric difference, subset and superset checks, and isdisjoint are all available. The mechanics are identical to the mutable set versions.
Operator results between two frozensets are themselves frozensets. The method forms (viewed.union(purchased), viewed.intersection(purchased), etc.) also return frozensets when called on a frozenset. That keeps the immutability promise: you can chain set algebra without ever creating a mutable result by accident.
The read-only half of the set API carries over to frozenset without changes.
Anything that would modify the frozenset is gone. The methods aren't defined on the type at all, and in-place operators raise type errors.
The error is AttributeError, not TypeError, because the method literally doesn't exist on frozenset. The same is true for remove, discard, pop, clear, and update (along with intersection_update, difference_update, and symmetric_difference_update).
The in-place operators behave a little differently. They exist on frozenset in the sense that Python will try them, but they raise TypeError because there's no way to mutate a frozenset.
If you genuinely want a "frozenset with one more element", you build a new frozenset from the union:
The name permissions now points at a new frozenset. The original object wasn't modified, you just rebound the name. That's a normal Python pattern with immutable values (strings and tuples work the same way).
Here's a quick mental model for what's available and what isn't:
The left branch is the half of the set API that carries over unchanged. The right branch is the half that's blocked, either by the method not existing (AttributeError) or by the operator refusing to mutate the receiver (TypeError).
The point of frozenset is that it's hashable. Once you have that, three patterns open up that a regular set can't support.
A common case is mapping a group of attributes to some computed value. With a regular set you couldn't do this. With a frozenset, the group itself becomes the key.
The order of tags doesn't matter at the call site, because the lookup key is a frozenset. {"sale", "electronics"} and {"electronics", "sale"} produce the same key and hit the same entry. That's a property you can't get with a list or tuple key, where order would matter.
You can build a set of frozensets, which is the natural way to track "the distinct combinations of X we've seen".
Three unique combinations from five filter applications. The two {"electronics", "sale"} requests and the {"sale", "electronics"} request all collapse into one entry, because frozensets compare by contents and hash to the same value regardless of insertion order.
Cost: frozenset membership and hashing are both O(1) on average. Building a set of frozensets is the same cost as building a set of strings or integers: linear in the total number of elements you put in.
frozenset is the right choice for a fixed lookup table of allowed values. Because it's immutable, you can declare it at module scope and trust that no other code can corrupt it.
If VALID_ORDER_STATUSES were a regular set, any caller could .add("returned") to it and silently expand the allowed list for the rest of the program. Making it a frozenset documents that the list is fixed and stops accidental mutation at the type level. There's a real production lesson there: any constant that ought to behave like a constant should be immutable.
A direct comparison of the two types:
| Feature | set | frozenset |
|---|---|---|
| Literal syntax | Yes ({1, 2, 3}) | No (constructor only) |
| Mutable | Yes | No |
| Hashable | No | Yes |
| Usable as a dict key | No | Yes |
| Usable as an element of another set | No | Yes |
add, remove, discard, pop, clear | Yes | No (AttributeError) |
update and ` | =, &=, -=, ^=` | Yes |
in, len, iteration | Yes | Yes |
union, intersection, difference | Yes | Yes |
issubset, issuperset, isdisjoint | Yes | Yes |
| Average lookup cost | O(1) | O(1) |
| Equality with the other type | Equal if elements match | Equal if elements match |
The two types share the entire read-only API and the same average performance. The mutable methods are the dividing line, and hashability is the payoff for giving them up.
That last row deserves a closer look. A set and a frozenset with the same elements compare as equal under ==, even though they're different types:
Equality is based on the elements they contain, not the wrapping type. That's helpful when one part of your code uses a set and another part uses a frozenset for the same logical concept. They'll still compare equal where it matters. They are not the same object, though, and type(a) == type(b) returns False. If you need to distinguish them, check the type explicitly.
You can pass a set and a frozenset to the same operation. The mechanics work fine, but the return type depends on whether you use the operator form or the method form.
The operator looks at its left operand to decide what to build. If the left side is a set, you get a set back. If the left side is a frozenset, you get a frozenset back. The right operand is just a source of elements.
When you call f.union(s), the method is defined on frozenset, so the result is a frozenset. When you call s.union(f), the method is defined on set, so the result is a set. The rule is "whichever type owns the method you called".
The practical takeaway: if you want the result to be a frozenset, either start from a frozenset on the left, call the method on a frozenset, or wrap the result with frozenset(...) explicitly. Don't rely on accidentally getting the right type out of a mixed expression.
The explicit wrap is slightly more verbose, but it makes the intent obvious to anyone reading the code later. In code that mixes the two types, that clarity is usually worth the extra characters.
10 quizzes