Frozenset

A frozenset is set's immutable twin — the same fast membership and set algebra, but hashable and safe to use as a dict key or set element.

A frozenset is an immutable version of a set: it supports every read-only set operation (membership, union, intersection, subset tests, and more) but has no add, remove, or other mutating method. Because it can't change after creation, a frozenset is hashable whenever its elements are, which lets it do something a plain set never can — serve as a dictionary key or live inside another set.

Creating a Frozenset

There is no frozenset literal syntax; it's built only with the frozenset() constructor, applied to any iterable.

fs = frozenset([1, 2, 3, 2])
print(fs)              # frozenset({1, 2, 3})
empty = frozenset()      # empty frozenset
from_set = frozenset({"a", "b"})

Immutability

frozenset has no add(), remove(), discard(), pop(), or clear() — any attempt to call them raises AttributeError, since those methods simply don't exist on the type.

fs = frozenset([1, 2, 3])
fs.add(4)      # AttributeError: 'frozenset' object has no attribute 'add'

Hashability

Because a frozenset can't change after construction, it is hashable as long as every element it contains is itself hashable — which is exactly the property that lets it act as a dict key or live inside a regular set (something a mutable set can never do).

groups = {
    frozenset(["admin", "editor"]): "elevated",
    frozenset(["viewer"]): "read-only",
}
groups[frozenset(["admin", "editor"])]   # "elevated"

s = {frozenset([1, 2]), frozenset([3, 4])}  # a set OF frozensets -- valid
bad = {set([1, 2])}                          # TypeError: set is unhashable

Set Operations Still Work

All the non-mutating set operations — union, intersection, difference, symmetric difference, subset/superset tests, membership — work identically on frozensets, and can mix freely with plain sets. The result type follows the left operand.

a = frozenset([1, 2, 3])
b = {2, 3, 4}          # a plain set

a | b       # frozenset({1, 2, 3, 4}) -- result is a frozenset
b | a       # {1, 2, 3, 4}            -- result is a plain set
a & b       # frozenset({2, 3})
2 in a         # True

Differences from set: Quick Reference

Feature                  set             frozenset
Literal syntax            {1, 2, 3}       none -- must use frozenset(...)
Mutable                    yes             no
add/remove/discard/pop      yes             no (AttributeError)
Hashable                     no              yes (if elements are hashable)
Usable as dict key            no              yes
Usable as element of a set     no              yes
Union/intersection/etc.        yes             yes (read-only ops)

Nested Set Use Cases

The main reason frozenset exists is to represent 'a set' in a context that itself requires hashable values: sets of sets, or dict keys that are naturally unordered groups (permission sets, tag combinations, graph edges as unordered vertex pairs).

# graph edges as unordered pairs, deduplicated automatically
edges = {frozenset([1, 2]), frozenset([2, 1]), frozenset([2, 3])}
print(len(edges))    # 2 -- {1,2} and {2,1} are the same frozenset

# memoizing a function keyed by an unordered set of arguments
cache: dict[frozenset[int], int] = {}
def cached_sum(nums: set[int]) -> int:
    key = frozenset(nums)
    if key not in cache:
        cache[key] = sum(nums)
    return cache[key]

Practical Usage and When to Choose It

Reach for frozenset specifically when you need set semantics (uniqueness, fast membership, set algebra) combined with hashability or true immutability — as a dict key, as an element of another set, or simply to guarantee a collection of unique values can never be accidentally mutated after it's built.

VALID_ROLES = frozenset({"admin", "editor", "viewer"})

def check_role(role: str) -> bool:
    return role in VALID_ROLES   # fast, and VALID_ROLES can't be tampered with

Key Takeaways

frozenset is the immutable counterpart of set: same read operations, no mutating methods.

It has no literal syntax — always built with frozenset(iterable).

It is hashable when its elements are, so it can be a dict key or live inside another set, unlike a plain set.

Union, intersection, difference, and membership tests all work the same as on a regular set.

Use it when you need an unordered, unique collection that must be hashable or must never change after creation.

fs.add(4) # AttributeError fs = fs | {4} # build a new frozenset instead frozenset has no mutating methods at all; any 'change' must produce a new frozenset via an operator or the constructor.

cache = {my_set: value} # TypeError: unhashable cache = {frozenset(my_set): value}

A plain set is unhashable and can't be a dict key; convert it to a frozenset first when the key needs to represent an unordered group.

Why can a frozenset be used as a dictionary key while a set cannot?

Dict keys must be hashable, and hashability requires the object's value to never change; a frozenset is immutable so it can compute and cache a stable hash, while a mutable set cannot.

How do you create an empty frozenset, and why is there no literal syntax for it?

With frozenset() (no arguments); there's no {} -style literal because {} is already reserved for an empty dict and set has no empty literal either -- both rely on their constructor for the empty case.

What happens if you call fs.add(x) on a frozenset fs?

AttributeError, because frozenset simply does not define an add method (or any other mutating method) -- it isn't a runtime-enforced immutability check, the method doesn't exist at all.

Give a practical example where you'd store frozensets as elements of another set.

Deduplicating undirected graph edges: representing each edge as frozenset({u, v}) makes edge (1,2) equal to edge (2,1), so storing them in a set of frozensets automatically merges duplicates.

If a = frozenset([1,2]) and b = {2,3}, what type does a | b return? What about b | a?

a | b returns a frozenset ({1,2,3}) because the left operand's type determines the result type; b | a returns a plain set with the same elements, because there b is on the left.

Write a function that groups words by their set of unique letters, using frozenset as the grouping key.

def group_by_letters(words: list[str]) -> dict[frozenset[str], list[str]]: groups: dict[frozenset[str], list[str]] = {} for word in words: key = frozenset(word.lower()) groups.setdefault(key, []).append(word) return groups result = group_by_letters(["tea", "eat", "ate", "bat"]) for key, words in result.items(): print(sorted(key), words)

'tea', 'eat', and 'ate' all share the letter set {'a','e','t'}, so they group together under one frozenset key; 'bat' gets its own group under {'a','b','t'}.

What does each line print?

True, then False, then True. a == b is True because frozensets compare by contents; a is b is False because they're two separate objects; hash(a) == hash(b) is True because equal frozensets are guaranteed to hash equally, which is what makes them safe as dict/set keys.

Write a function that returns True if a collection of tag-sets contains any exact duplicate tag-set, using frozensets.

def has_duplicate_tagset(tag_groups: list[set[str]]) -> bool: seen: set[frozenset[str]] = set() for tags in tag_groups: key = frozenset(tags) if key in seen: return True seen.add(key) return False print(has_duplicate_tagset([{"a", "b"}, {"c"}, {"b", "a"}])) # True

Converting each set of tags to a frozenset makes it hashable and order-independent, so {'a','b'} and {'b','a'} are recognized as the same tag-set when checked against the seen collection.

a = frozenset([1, 2, 3])
b = frozenset([1, 2, 3])
print(a == b)
print(a is b)
print(hash(a) == hash(b))