Python Part I · Module 7 · Sets
Changing a Set: add, update, remove, discard
There is a misconception about sets that is worth clearing up before anything else, because it comes directly from something true that you learned last chapter. Set elements must be hashable, which rules out lists — and a great many people take that to mean the set itself cannot be changed. It can. A set is a mutable collection: you can add to it and remove from it, and it is the same set afterwards. Those are two separate claims about two different things, and keeping them apart is most of what this chapter is for. The rest is four methods, two of which differ only in what they do when the item you named is not there.
Foundation14 min readPythonPart IFundamentalsSetsMutation
Part I · Module 7 · Chapter 7.3 · Changing a Set: add, update, remove, discard
1. The Set Is Mutable. Its Elements Must Be Hashable.
Two claims, about two different things, constantly collapsed into one:
| Claim | About | True? |
|---|---|---|
| A set can be changed after it is created | the collection | yes |
| A set's elements must be hashable | the items inside it | yes |
| A set cannot be changed | the collection | no |
Chapter 7.1 established the second claim, with {["a"]} raising TypeError: unhashable type: 'list'. The mistake is to carry that restriction up to the container and conclude the set is frozen too. It is not.
Here is the proof:
failed = {"burst"}
print(len(failed))
failed.add("reset")
print(len(failed))
print(sorted(failed))1
2
['burst', 'reset']One element before, two after, and failed was never reassigned — there is no failed = on that middle line. The set itself changed.
Note also how the "after" state was shown: len() and sorted(). Chapter 7.2 measured why printing a set directly is not something to rely on, and that rule applies to every before-and-after check in this chapter.
2. add(): One Element
s = {"a"}
print(s.add("b"))
print(sorted(s))None
['a', 'b']Two things in that output. The set gained its element — and add evaluated to None.
That is the rule Chapter 5.3 established for every mutating method: the change already happened to the collection you have, so there is nothing useful to hand back. Which means the same trap applies:
Adding something already present is not an error and changes nothing:
t = {"burst", "reset"}
print(len(t))
t.add("burst")
print(len(t))
print(t == {"burst", "reset"})2
2
TrueTwo before, two after, and the set is equal to what it was. This is Chapter 7.1's uniqueness showing up as behaviour: there is no way for a set to hold "burst" twice, so adding it again has nothing to do. add is safe to call without knowing whether the item is already there — a genuinely useful property when you are collecting names as you find them.
3. update(): Elements From an Iterable
add takes one value. update takes a collection and adds each of its elements:
sigs = {"clk"}
sigs.update(["valid", "ready"])
print(len(sigs))
print(sorted(sigs))3
['clk', 'ready', 'valid']Three elements, all strings, nothing nested. It works from any iterable — another set, or a string supplying its characters:
v = {"a"}
v.update({"b", "c"})
print(len(v), sorted(v))
w = {"x"}
w.update("yz")
print(len(w), sorted(w))3 ['a', 'b', 'c']
3 ['x', 'y', 'z']That second one is the same iterable rule Chapters 5.6 and 6.6 met: a string hands over its characters. Rarely what you want, and worth recognising when it happens.
4. add and update: The Shape Difference
Chapter 5.6 drew this line for append and extend. On a set it has a sharper edge.
add of a tuple adds one element, which happens to be a tuple:
u = {"clk"}
u.add(("valid", "ready"))
print(len(u))
print(("valid", "ready") in u)2
TrueTwo elements: the string "clk" and one tuple. Exactly like append nesting a list inside a list.
But add of a list does not merely produce an odd shape — it raises:
s = {"a"}
s.add(["b", "c"])TypeError: unhashable type: 'list'Chapter 7.1's rule, enforced at the moment you try. A list cannot be a set element, so there is no wrong-shaped result to end up with. update is what you wanted:
s = {"a"}
s.update(["b", "c"])
print(len(s), sorted(s))3 ['a', 'b', 'c']And update needs something with elements to hand over:
s = {"a"}
s.update(5)TypeError: 'int' object is not iterableIterable again — Chapter 5.9's word. A single number has no contents to distribute.
5. remove() and discard(): Identical, Until the Item Is Absent
When the element is present, these two do exactly the same thing:
r = {"burst", "reset"}
r.remove("burst")
print(sorted(r), len(r))['reset'] 1d = {"burst", "reset"}
d.discard("burst")
print(sorted(d), len(d))['reset'] 1Same result. The entire difference appears when the element is not there.
remove raises:
s = {"reset"}
s.remove("missing")KeyError: 'missing'discard does nothing at all:
d2 = {"reset"}
d2.discard("missing")
print(sorted(d2), len(d2))
print(d2 == {"reset"})['reset'] 1
TrueNo error, no change, and the set is equal to what it was.
Both return None, like every mutating method here:
x = {"a", "b"}
print(x.remove("a"))
print(x.discard("zzz"))None
NoneWhich one to use
This is a design decision, not a preference, and it is worth stating as one:
Until Module 9 gives you if, checking first with in from Chapter 7.2 is the way to make remove safe when you are unsure — or simply use discard.
One more verified case, because it is the one that catches people in real scripts:
set().remove("x")KeyError: 'x'An empty set is a completely normal state — nothing failed yet — and remove on it raises.
6. frozenset: The Read-Only Variant
The canonical curriculum names this here, and it takes one section.
A frozenset is a set that cannot be changed. Everything about reading it is identical:
fz = frozenset({"clk", "reset_n"})
print(type(fz))
print(len(fz))
print("clk" in fz)
print(sorted(fz))<class 'frozenset'>
2
True
['clk', 'reset_n']Length, membership and sorted() all work. What is missing is everything in this chapter:
f = frozenset({"a"})
f.add("b")AttributeError: 'frozenset' object has no attribute 'add'AttributeError, not TypeError — the method does not exist, exactly as Chapter 6.4 found for tuples and their missing list methods. The same relationship holds here:
| Changeable | Read-only |
|---|---|
list | tuple |
set | frozenset |
And it buys the same thing it bought a tuple. Because a frozenset cannot change, it is hashable — so it can be a set element where an ordinary set cannot:
holder = {frozenset({"a", "b"})}
print(len(holder))
print(frozenset({"a", "b"}) in holder)1
TrueCompare that with {{"a"}} from Chapter 7.1, which raised unhashable type: 'set'. The restriction was never arbitrary: a set cannot be an element because a set can change, and a frozenset can be one because it cannot.
Equality works across the two, since both compare by membership:
print(frozenset({"a"}) == {"a"})True7. What Each Operation Does
Now that each has arrived from a problem — a summary, not the lesson:
| Operation | Changes the set? | When the item is absent |
|---|---|---|
s.add(x) | adds one element | n/a — adding an existing item is a no-op |
s.update(xs) | adds each element of xs | n/a |
s.remove(x) | removes x | raises KeyError |
s.discard(x) | removes x | does nothing |
Every one of these evaluates to None, so none of them is ever assigned — the rule from Section 2.
8. Predict, Run, Observe
Predict every line before reading on. The duplicate handling is the part to be careful with.
failed = {"burst", "reset"}
failed.add("burst")
print(len(failed))
failed.update(["crc", "reset"])
print(len(failed))
print(sorted(failed))
failed.discard("nope")
print(len(failed))
print(failed == {"burst", "reset", "crc"})
print(failed.add("timeout"))
print(len(failed))The verified output:
2
3
['burst', 'crc', 'reset']
3
True
None
4Trace it one step at a time.
add("burst") leaves the length at 2 — already present, nothing to do.
update(["crc", "reset"]) is handed two values of which only "crc" is new, so the length goes to 3, and sorted() confirms exactly which three.
discard("nope") leaves it at 3: the element was absent, and discard does nothing about that.
The equality check is True, which is the cleanest possible confirmation of the state — it compares membership and ignores everything else.
Then print(failed.add("timeout")) prints None, because that is what add evaluates to — and the very next line shows 4, because the element really was added. Both are true at once, which is exactly why assigning the result is such a dangerous mistake.
9. Exercises
Exercise 1 — before and after. Starting from sigs = {"clk"}, write the statements that: add "reset_n", add every name from ["valid", "ready"], remove "clk" in a way that raises if it is missing, and remove "nope" in a way that does not. Print len() and sorted() after each.
Exercise 2 — predict the length. Starting from s = {"a", "b"} each time, give len(s) afterwards:
(a) s.add("c") (b) s.add("a") (c) s.update(["a", "c"]) (d) s.update("cd") (e) s.discard("z") (f) s.remove("a")
Exercise 3 — add or update. For each, say which method you want and what the other one would do:
(a) record one newly failing test
(b) merge in every failure from a second run
(c) store a (test, seed) pair as one item
(d) add every character of a string as a separate item
Exercise 4 — read the error. Run {"a"}.add(["b"]) and write down the exact message. Explain in two sentences why this is not a shape problem like append had in Chapter 5.6, and give the corrected call.
Exercise 5 — remove or discard. For each situation, choose one and justify in one sentence: (a) removing a test from a set you built one line earlier from the same source (b) clearing a name that may or may not have been recorded (c) removing an entry you believe must exist, where its absence would mean your bookkeeping is wrong
Exercise 6 — the None trap. This prints None and then fails later. Explain the three steps that produced it and fix it with the smallest change:
failed = {"burst"}
failed = failed.add("reset")
print(failed)Exercise 7 — the two claims. In two sentences, state what is mutable about a set and what must be immutable, and name the error you would see if you got the second one wrong.
Exercise 8 — frozenset. Predict which of these succeed, verify all four, and explain the pattern in two sentences:
(a) {frozenset({"a"})} (b) {{"a"}} (c) frozenset({"a"}) == {"a"} (d) frozenset({"a"}).add("b")
10. Summary
- A Python set is mutable. It can be changed after it is created; it is the elements that must be hashable. Two separate claims about two different things.
add(x)adds one element and evaluates toNone. Adding something already present is a harmless no-op, soaddis safe to call without checking.update(xs)adds each element of an iterable. Given a string it adds the characters.addof a list raisesTypeError: unhashable type: 'list'— not a wrong shape, a rejected element. You wantedupdate.updateof a non-iterable raisesTypeError: 'int' object is not iterable.remove(x)anddiscard(x)are identical whenxis present. When it is absent,removeraisesKeyErroranddiscarddoes nothing.- Choose by what absence means —
removewhen it would indicate a bug,discardwhen it is a normal outcome. Neither is generally safer. - Every one of these returns
None.s = s.add(x)setsstoNone, silently. frozensetis the read-only variant, standing tosetastuplestands tolist..addon one is anAttributeError.- A frozenset is hashable, so it can be a set element where a plain set raises
unhashable type: 'set'. - When a collection changes in place, look at it. Print
len()orsorted()after each step rather than reasoning about accumulated state.
Three chapters have built up what a set is. The next one is what sets are actually for.
Where this fits
Part of the Python curriculum.
