AlgoMaster Logo

Set Methods

High Priority23 min readUpdated June 6, 2026
Listen to this chapter
Unlock Audio

Sets ship with a small but focused group of mutation methods: ways to add a single element, ways to remove an element (with or without a crash on a miss), bulk merges with any iterable, and in-place versions of the union, intersection, difference, and symmetric difference operations. This lesson walks through each one in an e-commerce setting, where a set is usually tracking active customers, products on sale, or tags applied to an order.

add: Insert a Single Element

add(elem) inserts elem into the set. If the element is already there, nothing happens. The set is mutated in place, and the call returns None.

The set grows by one entry. The order in the output may differ on your machine, since sets don't keep a defined iteration order.

The interesting property is that add is idempotent: calling it with an element that's already in the set is a no-op. No error, no duplicate.

Three calls, but the set still has one element. This is exactly the property that makes sets useful for "have I seen this before?" tracking. You can add blindly without first checking with in, and the set takes care of deduplication.

A common e-commerce use: tracking which customers have placed at least one order, where you don't care how many orders they placed, only whether they're active.

Five rows in, three customers out. The set quietly dropped the duplicate entries for Alice and Bob.

add only accepts hashable elements. Trying to add a list raises TypeError, since lists aren't hashable.

If you want to store something list-like, convert it to a tuple first: tags.add(("sale", "new")). Tuples of hashable elements are themselves hashable.

remove vs discard: Two Ways to Take One Out

Both remove(elem) and discard(elem) delete a single element from the set. The difference is what happens when the element isn't there.

remove raises KeyError on a miss:

That worked because "bob@example.com" was in the set. Try the same call with a missing element:

discard does the same removal but stays silent on a miss:

The first discard does nothing, because the element wasn't there. The second one removes "alice@example.com". No traceback, no exception. The set ends up empty, which is printed as set() rather than {} (since {} means an empty dict, not an empty set).

The choice between the two is the same kind of choice as d[key] versus d.get(key) for dictionaries. Use remove when the element being absent would mean a real bug and a crash is the most informative thing to do. Use discard when "maybe present, maybe not" is a normal case and you just want it gone if it's there.

MethodIf element presentIf element missingReturns
s.remove(elem)Removes itRaises KeyErrorNone
s.discard(elem)Removes itDoes nothingNone

A practical e-commerce example: when a customer unsubscribes, you want to drop them from the active set. You probably don't know for sure whether they were active to begin with (maybe they never placed an order), so discard is the right tool.

Bob is removed because he was active. Dave's unsubscribe is a no-op because he wasn't in the set. The function doesn't have to care which of the two cases it's hitting.

What's Wrong with This Code?

The first call succeeds because Bob was in the set. The second call crashes because Dave never was. The function uses remove, which raises on a miss, but the calling code can't easily tell whether an email is in the active set before calling. The right tool for "remove this if present, otherwise do nothing" is discard.

Fix:

Same logic, but discard swallows the miss instead of crashing. If you ever find yourself wrapping remove in a try/except KeyError, you almost certainly want discard instead.

The decision is easiest to picture as a flow:

The decision point is whether absence is meaningful. If absence indicates a programming mistake or a corrupted state, remove and its KeyError are exactly what you want, because the crash tells you something is wrong. If absence is a normal case ("maybe this customer was active, maybe not"), discard keeps the code straight.

pop: Remove and Return an Arbitrary Element

pop() removes one element from the set and returns it. Unlike dict.popitem, you don't get to choose which one comes out, and the element isn't taken in any predictable order.

On your machine the removed element might be "new" or "featured" instead. The result depends on how the hash table is laid out, which depends on the hash values of the elements and the table's current size. It's not random in the random.choice sense, but it's not LIFO like dict.popitem either.

This is what "arbitrary" means in the Python docs. The element insertion order does influence the layout (you don't get pure randomness), but you should treat the choice as unpredictable for your purposes. If you need to take a specific element out, look it up by value with remove or discard. If you need to take elements in insertion order, you're using the wrong data structure; reach for a list or a collections.deque.

pop on an empty set raises KeyError:

There's no default-accepting form like dict.pop(key, default). If you might be popping from an empty set, guard with a while s: check or wrap the call in a try/except.

The exact order varies. The pattern itself, while s: s.pop(), is the cleanest way to drain a set when you don't care about ordering. It's common in "process every pending job once" loops where the work queue is naturally a set (because jobs shouldn't be duplicated).

clear: Empty the Set in Place

clear() removes every element. It mutates the set in place and returns None.

Just like with lists and dicts, there's a difference between clearing a set in place and rebinding the name to a new empty set. The distinction matters when something else holds a reference to the same set.

clear empties the underlying set, and saved points to that same object, so it sees the change.

active_b = set() doesn't empty the existing set, it rebinds the name active_b to a brand-new empty set. The original set is untouched, and saved still holds onto it.

FormMutates the existing set?Other references see the change?
s.clear()YesYes
s = set()No (creates a new set)No

If you only have one name pointing to the set, the two forms look identical. The moment something else holds a reference (a function caller, an attribute on a class, a stored value in a dict), the choice matters.

copy: Shallow Copy

copy() returns a new set containing the same elements as the original. Modifying one set afterward doesn't affect the other.

Adding to duplicate didn't touch original. That's the whole point: copy gives you an independent set object.

The "shallow" qualifier matters for dicts and lists, where values can themselves be containers. For sets, the qualifier is mostly academic, because every element must be hashable, and the everyday hashable types (numbers, strings, tuples of hashables, frozenset) are immutable. There's no way for "the same element" in two sets to be silently mutated through one and visibly change in the other.

What copy does give you is a distinct set object, so adds and removes on one don't show up in the other.

Without copy, the assignment duplicate = original doesn't make a new set, it just gives the same set a second name. duplicate.add("carol@example.com") reaches the same object, and original sees the change. Compare with the explicit copy:

This is the same trap that hits lists and dicts. Plain assignment never copies a mutable container; reach for copy() (or set(other)) when you need an independent set.

update: In-Place Union with Any Iterable

update(iterable, ...) adds every element from one or more iterables into the set, skipping anything already present. It mutates the set in place and returns None.

The duplicate "Wireless Mouse" was silently ignored because it was already in on_sale. The other two are inserted. update is in-place: there's no return value to assign, just the side effect on the set.

The key thing that separates update from the result-returning union method is that update accepts any iterable. A list, a tuple, a generator, a string (which iterates as its characters), a dict (which iterates as its keys), all are fair game. The | operator and the union method demand sets (or set-like things) on both sides.

The list, tuple, and string are all valid inputs. The string "ab" iterates as its two characters, so "a" and "b" go in. That's a real footgun: if you meant to add the whole string "ab" as one element, you want add, not update. update always treats its argument as an iterable of elements to insert.

You can pass multiple iterables in one call:

Each argument is iterated in turn, and every element from each is added. This is convenient when you have items coming from several sources and want one merge call instead of several.

The |= operator is the operator form for the dict-style case where you're merging another set in:

s |= other is equivalent to s.update(other) when other is a set. The method form is broader, because it accepts any iterable; the operator form requires a set on the right.

In-Place Intersection, Difference, and Symmetric Difference

The result-returning methods intersection, difference, and symmetric_difference each have an in-place version that mutates the receiver instead of producing a fresh set.

Result-ReturningIn-Place
s.intersection(other) / s & others.intersection_update(other) / s &= other
s.difference(other) / s - others.difference_update(other) / s -= other
s.symmetric_difference(other) / s ^ others.symmetric_difference_update(other) / s ^= other

The in-place versions return None and modify s directly. Like update, all three accept any iterable, not just a set.

intersection_update(iterable, ...) keeps only the elements of s that are also in every iterable passed in. Everything else gets dropped.

After the call, in_stock contains only the products that were both in stock and on sale. USB Cable and Webcam were in stock but not on sale, so they were dropped. Keyboard was on sale but not in stock, so it never gets added either; intersection_update only filters, never inserts.

difference_update(iterable, ...) removes from s every element that appears in any of the iterables.

Every email in unsubscribed was dropped from active_customers. This is the bulk equivalent of calling discard in a loop, but with the benefit of being one expressive call.

symmetric_difference_update(other) keeps the elements in exactly one of the two sets and drops the elements in both. Unlike the other two in-place mutators, it accepts only one argument (because "symmetric difference of three sets" isn't a meaningful single operation).

HDMI Cable was in both, so it dropped out. The two Wireless Mouse and USB Cable items were only in yesterday's sale; the two Webcam and Keyboard items were only in today's. All four stay. The result is "what changed between yesterday and today".

Here's how each in-place mutator transforms the same starting set:

Same starting set, same other, four different ending states. update only grows the set; intersection_update only shrinks it; difference_update removes a chunk; symmetric_difference_update swaps in the "exclusive to other" elements while removing the shared ones.

The choice between the in-place form and the result-returning form comes down to whether you actually want a new set or are happy to mutate the existing one. If you need to keep the original around for other code to use, return-a-new-set is the safe pick. If you're done with the original and just want it to become the new value, the in-place form skips an allocation.

Method Reference

A consolidated table of every mutation method covered in this lesson, for quick lookup.

MethodMutates?ReturnsRaisesExample
s.add(elem)YesNoneNever (element must be hashable)s.add("Mouse")
s.remove(elem)YesNoneKeyError on misss.remove("Mouse")
s.discard(elem)YesNoneNevers.discard("Mouse")
s.pop()YesRemoved elementKeyError on emptyremoved = s.pop()
s.clear()YesNoneNevers.clear()
s.copy()NoNew setNeverdup = s.copy()
s.update(iterable, ...)YesNoneNevers.update(["A", "B"])
s.intersection_update(iterable, ...)YesNoneNevers.intersection_update(other)
s.difference_update(iterable, ...)YesNoneNevers.difference_update(other)
s.symmetric_difference_update(other)YesNoneNevers.symmetric_difference_update(other)

Two patterns to notice. First, every mutating method except pop returns None. pop returns the removed element because that's the whole point of the call. Second, the methods split into "strict" ones (remove, pop) that raise on a missing element or empty set, and "lenient" ones (discard) that stay silent. There's no defaulted form of pop for sets, unlike dict.pop(key, default); on an empty set, pop always raises.

Every method in this table that mutates the set (which is all of them except copy) is unavailable on a frozenset. A frozenset is immutable, so it has copy and the result-returning set operations, but no add, remove, discard, pop, clear, update, or any of the *_update methods. That's not an oversight; it's the whole point of having a frozen variant.

Quiz

Set Methods Quiz

10 quizzes