skip to content

Which methods does frozenset omit that set provides, and why?

level: middleimportance: should knowfreq 40%

answer

  1. What does immutable actually remove?
  2. The read side survives, the write side goes
  3. No in-place operator hooks either
  4. A mutator would invalidate the hash
  5. |= falls back to __or__ and rebinds

basics

~20 s

frozenset drops every in-place mutator: add, clear, pop, remove, discard, update and the three other update variants, plus the augmented operator hooks. Everything that only reads a set survives, and each returns a new frozenset instead of changing the receiver.

solid answer

~50 s

`frozenset` is `set` minus mutation. Gone are `add`, `clear`, `pop`, `remove`, `discard`, `update`, `difference_update`, `intersection_update` and `symmetric_difference_update`, together with the in-place operator hooks `__ior__`, `__iand__`, `__isub__` and `__ixor__`. What stays is the read side — membership, iteration, `len`, `issubset`, `issuperset`, `isdisjoint` and the four algebra methods — and every one of those returns a fresh object instead of editing the receiver. The reason is the hash: `frozenset` publishes a `__hash__` derived from its members, and a mutator would invalidate that hash while the object sat in a bucket of some dict or set that indexed it. The methods are absent rather than present-and-raising, so mistakes surface as `AttributeError` at the call site. Note that `fs |= other` still works: with no `__ior__`, Python falls back to `__or__` and rebinds the name to a new frozenset.

code

python · 6 lines
python
print(sorted(set(dir(set())) - set(dir(frozenset()))))

fs = frozenset({"billing"})
before = fs
fs |= {"urgent"}
print(before, fs, before is fs)

go deeper

for a junior

Recall the shape: frozenset supports everything that reads a set and nothing that changes one. Being able to say that add and remove are simply absent, not present-but-raising, is the level bar here.

for a middle

Name the omitted mutators and the in-place operator hooks, and give the reason — a published hash cannot survive mutation. Expect a follow-up asking what |= does when __ior__ is missing.

for a senior

Show where the immutable surface changes a design: a frozenset held as a dict key or a set member never drifts, so shared read-only configuration needs no defensive copying. Be explicit that rebinding a name is not mutating an object.

for a principal

The tradeoff you own is where in a data flow the freeze happens. Freeze too early and every edit rebuilds; freeze at the key or cache boundary and you buy safe sharing for the cost of one allocation.

### frozenset is set minus mutation The two types share a C implementation and almost all of a method table. What `frozenset` leaves out is exactly the write side. You can compute the difference at runtime: ```python print(sorted(set(dir(set())) - set(dir(frozenset())))) ``` On 3.14 that prints: ``` ['__iand__', '__ior__', '__isub__', '__ixor__', 'add', 'clear', 'difference_update', 'discard', 'intersection_update', 'pop', 'remove', 'symmetric_difference_update', 'update'] ``` Nine named mutators and four in-place operator hooks. Nothing else differs — membership, iteration, `len`, `issubset`, `issuperset`, `isdisjoint`, `union`, `intersection`, `difference`, `symmetric_difference` and `copy` are all present, and each of the algebra methods returns a brand-new object rather than editing the receiver. ### Why the omission is structural, not stylistic `frozenset` publishes a `__hash__` computed from its members. The moment a mutator existed, an object already sitting in a dict bucket or inside another set could change hash under the table that indexed it, and every subsequent lookup would miss. The methods are absent rather than present-and-raising for the same reason `list.__hash__` is `None`: the type is telling you at the API level what it can and cannot promise, so the mistake is caught by `AttributeError` at the call site rather than by a mysterious cache miss in production. ### The `|=` trap Because there is no `__ior__`, this still runs: ```python fs = frozenset({"billing"}) before = fs fs |= {"urgent"} print(before) # frozenset({'billing'}) -- untouched print(fs) # frozenset({'billing', 'urgent'}) print(before is fs) # False ``` Python's augmented-assignment protocol tries the in-place hook first and falls back to the ordinary binary operator plus a rebinding. So `fs |= other` is `fs = fs | other`: a new object, and the name now points at it. Read casually it looks like mutation, and that is precisely why it bites — any other name, dict key or set element still holding the original sees the old value. When you intend a shared structure to be updated, a frozenset is the wrong tool; when you intend a snapshot, this is the behaviour you want. Note also which type comes out of a mixed expression: the left operand decides. A `frozenset` on the left yields a `frozenset`; a `set` on the left yields a `set`. If the result is destined for a dict key, put the frozen operand first — or wrap the result in `frozenset()` and stop thinking about it. ### `copy()` and subclassing `frozenset.copy()` on an exact frozenset returns the receiver itself in CPython — the copy *is* the original object — because duplicating an immutable object buys nothing. A subclass instance does get a real new object (a plain `frozenset`, not the subclass). That identity shortcut is a CPython implementation detail rather than a language guarantee, but it is a good illustration of what immutability buys the runtime: frozen objects can be shared freely instead of defensively copied. Subclassing `frozenset` to sneak mutation back in does not work either. Contents are fixed by `__new__` at construction, and there is no supported hook to add members afterwards. If you need both mutation and hashing, the honest design is a mutable `set` you edit plus a `frozenset` snapshot taken at the moment you need a key. ### Where the immutable surface earns its place Three patterns come up repeatedly. Module-level constants — a set of allowed values that no caller should be able to extend — are safer as a `frozenset`, since a stray `update()` on a shared `set` becomes an `AttributeError` instead of a global data change. Default parameter values are safe as a `frozenset` for the same reason a `set()` default is a bug. And any value that will be used as a key, cached, or handed to code you do not control is worth freezing on the way out, because the type itself then enforces the contract you would otherwise have to document. The cost is real but small: every "change" allocates a new object. For sets that are rebuilt in a tight loop, that allocation pressure matters and a mutable `set` is the right answer. For sets that are built once and read many times, immutability is free and the safety is worth having.

  • If frozenset has no `__ior__`, what happens on `fs |= {'urgent'}`?
    Python's augmented assignment tries the in-place hook, finds none, falls back to `__or__`, and rebinds the name to the brand-new frozenset it produced. The object the name previously referred to is untouched, so anything else holding it — a dict key, an element of another set — still sees the old value. It reads like mutation and is not, which is the trap when a frozenset is shared.
  • Does `frozenset.copy()` give you a distinct object?
    In CPython, not for an exact frozenset: the call returns the very same object, because duplicating an immutable object buys nothing. A subclass instance does get a real new object, and it comes back as a plain `frozenset`. This is an implementation detail rather than a language guarantee, but it illustrates the payoff of immutability — the runtime can share frozen objects instead of copying defensively.
  • Which type comes out of `frozenset({1}) | {2}` versus `{2} | frozenset({1})`?
    The left operand decides: the first yields a `frozenset`, the second a `set`. That matters when the result is destined to be a dict key or a set member, because a `set` result is unhashable and will fail later rather than at the expression. If the destination needs a frozen value, put the frozen operand first or wrap the result in `frozenset()`.

saying these in an interview costs you the question

  • Says frozenset is a set with a read-only flag
  • Thinks fs |= x mutates the frozenset in place
  • Believes frozenset loses union and intersection too
  • Claims a frozenset cannot be iterated
  • Assumes frozenset.add exists but raises at runtime

context