skip to content

Why does set.union accept a list argument when the | operator does not?

level: middleimportance: must knowfreq 45%

answer

  1. Two spellings for one algebra
  2. One spelling is fussy about types
  3. NotImplemented, then TypeError
  4. Methods swallow any iterable, several at once
  5. |= is strict, update is not

basics

~20 s

The operator forms are defined only between set and frozenset and raise TypeError on anything else, so mixed types fail loudly. The method forms are documented to accept any iterable and convert it as they go, and union, intersection and difference also take several iterables at once.

solid answer

~40 s

`|`, `&`, `-` and `^` are implemented on the set types for set operands only; given a list, `set.__or__` returns `NotImplemented`, Python finds no reflected handler, and you get `TypeError: unsupported operand type(s) for |: 'set' and 'list'`. The method spellings - `union`, `intersection`, `difference`, `symmetric_difference` and their `_update` counterparts - accept any iterable and consume it directly, and all of them except the symmetric-difference pair take multiple arguments (`a.difference(b, c)`). The strictness is deliberate: an operator between a set and a sequence is far more often a bug than an intention. The same split applies in place - `s |= other` demands a set, while `s.update(other, more)` will take any iterables.

code

python · 14 lines
python
a = {1, 2}
b = [2, 3]

print(sorted(a.union(b)))          # method form: any iterable
print(sorted(a.difference(b, {5})))  # and several of them

try:
    a | b
except TypeError as exc:
    print('operator form:', exc)

s = {1, 2}
s.update([3], (4,), {5: 'value'})  # a dict contributes its keys
print(sorted(s))

go deeper

for a junior

Know that both spellings exist and that the four operators combine two sets. Recall that passing a list to the operator form raises TypeError and that wrapping it in set(...) or calling the method form fixes it.

for a middle

Explain the mechanism: or returns NotImplemented for a non-set, no reflected handler exists, hence TypeError; methods iterate whatever they are given and accept several iterables. Know that a dict argument contributes its keys.

for a senior

Demonstrate the in-place judgement - |= mutates a shared object while = | rebinds one name - and pick the method form to avoid materialising a large intermediate set from a generator.

for a principal

Set the house rule: operators between real sets for readability, method forms at the boundaries where inputs are untyped iterables, and never leave code where a caller changing a return type from set to list silently changes behaviour instead of failing.

## Two spellings, one algebra, different contracts Python gives every set-algebra operation two spellings: | operation | operator | method | |---|---|---| | union | `a \| b` | `a.union(b, ...)` | | intersection | `a & b` | `a.intersection(b, ...)` | | difference | `a - b` | `a.difference(b, ...)` | | symmetric difference | `a ^ b` | `a.symmetric_difference(b)` | plus the in-place pairs `|=` / `update`, `&=` / `intersection_update`, `-=` / `difference_update`, `^=` / `symmetric_difference_update`. They compute identical results, but their input contracts differ in two ways that matter in real code. ## Difference one: operand types The operator forms require both operands to be `set` or `frozenset`. Python evaluates `a | b` by calling `type(a).__or__(a, b)`; the set implementation inspects `b`, sees a non-set, and returns `NotImplemented`. The interpreter then tries the reflected `type(b).__ror__`, and when a plain `list` offers none either, it raises: ``` TypeError: unsupported operand type(s) for |: 'set' and 'list' ``` The method forms take any iterable and iterate it. `{1, 2}.union([2, 3])` is fine, and so is `.union(range(5))`, a generator, a `str` (which contributes its characters), or a `dict` (which contributes its keys - a genuine trap when you meant the values). The asymmetry is a deliberate design choice, not an oversight. Between two sets, `|` reads as algebra. Between a set and a sequence it is almost always a mistake - someone forgot a `set(...)` call - and the language would rather fail at that line than silently produce a set from data that was never deduplicated on purpose. ## Difference two: arity `union`, `intersection` and `difference` accept any number of arguments: `a.difference(b, c, d)` removes everything in `b`, `c` or `d` in one pass, with no intermediate sets. `symmetric_difference` and `symmetric_difference_update` take exactly one argument, because the operation is not naturally n-ary - chained `^` is associative but means something unintuitive (elements appearing an odd number of times), so the API refuses to encourage it. `update` also accepts several iterables: `s.update(rows, extras, {'k': 'v'})`. ## The in-place forms `s |= other` is not merely `s = s | other`. The augmented form calls `set.__ior__`, which mutates `s` in place, so every other name bound to that same set object sees the change. `s = s | other` builds a new set and rebinds only the local name. When a set is shared - a module-level registry, an attribute on an object someone else holds - that distinction decides whether their view updates. And `|=` inherits the operator's strictness: `s |= [3]` raises `TypeError`, while `s.update([3])` works. ## Result types when set and frozenset mix The operators accept a `frozenset` on either side, and the result takes the type of the left operand: `frozenset({1}) | {2}` is a `frozenset`, `{1} | frozenset({2})` is a `set`. The method forms follow the receiver: `frozenset({1}).union([2])` is a `frozenset`. If a function must return something immutable, call the method on the frozenset, or wrap the result explicitly rather than relying on operand order. ## Which spelling to write With two sets in hand, the operators win on readability - `wanted & available` reads as a sentence and the precedence is the familiar one (`&` binds tighter than `^`, which binds tighter than `|`; use parentheses when mixing). Reach for the method form when the right-hand side is not a set and materialising it would be wasteful, when you have several right-hand sides, or when you want a reader to see the type-flexibility on purpose. One performance note about the difference between them: `a.union(big_generator)` consumes the generator lazily into a new set, whereas `a | set(big_generator)` builds an intermediate set first. On large inputs the method form is the leaner one. ## The mistake this question is really probing Candidates who have only ever written `a | b` between two literals are surprised that the same line fails when `b` arrives from a function returning a list, and they often conclude that sets 'do not work with lists'. The right mental model is narrower: the operators are typed strictly, the methods are typed loosely, and the fix is either `set(b)` or `a.union(b)` - chosen deliberately, not by trial and error.

  • How does `s |= other` differ from `s = s | other` when another name holds the same set?
    `|=` calls `set.__ior__`, which adds the elements to the existing set object, so every name bound to it - an alias, an attribute, a value in a registry - sees the new members. `s = s | other` builds a brand-new set and rebinds only the local name; the other holders still see the old contents. On a shared or module-level set that is the difference between a working update and a silent divergence.
  • Why does symmetric_difference take exactly one argument when union takes several?
    Union, intersection and difference have obvious n-ary readings, so their methods accept any number of iterables and do the work in one pass. Symmetric difference chained over three or more operands means elements that appear an odd number of times, which is rarely what anyone intends, so the API takes a single argument and forces you to write the chain explicitly with `^` if you truly want it.
  • What type is `frozenset({1}) | {2}`, and why does operand order matter?
    It is a `frozenset`: the operators produce the type of the left operand, so `{1} | frozenset({2})` is a mutable `set` instead. The method forms follow the receiver in the same way. If a function is contractually required to hand back something immutable, do not rely on which side the frozenset landed on - call the method on the frozenset, or wrap the result in `frozenset(...)`.

saying these in an interview costs you the question

  • Says sets cannot be combined with lists at all
  • Believes a | b silently converts the list operand
  • Thinks the operator and method forms are exact synonyms
  • Claims s |= other accepts any iterable like update
  • Assumes s = s | other updates every alias of the set
  • Expects symmetric_difference to accept several arguments

context