Sets earn their keep the moment you need to combine, compare, or contrast two groups of items: customers in two marketing campaigns, tags on two products, categories across two orders. This lesson covers the four core algebra operations on sets (union, intersection, difference, symmetric difference) along with the subset, superset, and disjoint checks. Each operation comes in two flavors: an operator form and a method form. They look similar but differ in one important way that trips up most beginners.
The union of two sets contains every element from either set, with no duplicates. The operator form is | and the method form is .union().
Every customer who saw either campaign appears once in reached. "bob" is in both campaigns but only shows up once, because sets don't keep duplicates. The order in the printed output isn't guaranteed: sets don't track insertion order, so you might see the names in a different sequence on your machine.
In set-builder language, a | b is "everything that's in a, or in b, or in both". You'll sometimes see this written as a ∪ b in math notation.
The method form does the same job but accepts any iterable, not just a set:
campaign_sms here is a list, not a set, and the method handles it fine. The operator version would have raised TypeError because | requires both operands to be sets. This difference matters often enough that it's worth flagging now: operators need sets on both sides; methods accept any iterable. We'll see the same pattern with intersection, difference, and symmetric difference.
The method also takes multiple iterables at once, which is handy when you have more than two groups to combine:
You can chain the operator too (a | b | c | d), but if any operand is a non-set iterable, only the method form works.
Neither form mutates the original sets. a | b and a.union(b) both return a new set; a and b are untouched.
Cost: Union runs in roughly O(len(a) + len(b)). Python walks every element of both sets and inserts it into the result, and each insert is O(1) on average.
The corresponding in-place version is |=, which adds every element of the right side into the left set:
|= mutates the left set in place and returns nothing useful. The equivalent named method is update().
The intersection of two sets contains only the elements that appear in both. The operator form is & and the method form is .intersection().
"bob" and "carol" are the only customers who saw both campaigns. "alice" saw only the email, and "dave" saw only the SMS, so neither is in the result.
In set-builder language, a & b is "everything that's in a and also in b". The math notation is a ∩ b.
A Venn-style diagram makes the regions easy to picture:
The center region is what & returns: the overlap. The two outer regions are what union picks up alongside the center.
The method form is intersection, and like union it accepts any iterable:
The same operator-vs-method rule applies: & insists on sets, intersection accepts a list, tuple, or any other iterable.
You can also intersect more than two groups at once. The result is the elements common to all of them:
Only "carol" appears in all three groups. As soon as one set excludes someone, they drop out of the result.
Cost: Intersection is roughly O(len(smaller) + len(larger)) on average, because Python iterates the smaller set and checks each element against the larger one in O(1) per check. Sizes matter: scanning a 10-element set against a million-element set is much cheaper than the reverse.
Neither & nor intersection mutates the original sets. The in-place version &= keeps only the elements that are also in the right side and discards the rest from the left set.
The difference a - b contains the elements that are in a but not in b. The method form is .difference().
"bob" and "dave" are removed because they appear in churned. "alice" and "carol" are kept because they don't.
Difference is the one operation in this lesson where order matters. a - b and b - a answer different questions:
only_email is "people who saw the email but not the SMS"; only_sms is "people who saw the SMS but not the email". Both are useful, but they answer different business questions, so you need to be deliberate about which side goes on the left.
In set-builder terms, a - b is "everything in a that is not in b". The math notation is a \ b or a − b.
The method form accepts any iterable, like the others:
"limited" was in catalog_tags and shows up in the iterable, so it's removed. "discontinued" is in the iterable but not in catalog_tags, so it's quietly ignored: difference only removes elements that are actually present.
You can pass multiple iterables to difference, and every element in any of them is removed from the left side:
"bob" is removed because of already_contacted. "dave" and "eve" are removed because of opted_out. The two iterables are effectively unioned together internally before being subtracted from the left.
Cost: Difference is roughly O(len(a)) on average. Python walks every element of a and asks the right-hand operand "do you contain this?", which is O(1) per check.
The symmetric difference a ^ b contains the elements that are in exactly one of the two sets. Anything in both is removed. The method form is .symmetric_difference().
"alice" saw only the email and "dave" saw only the SMS, so both appear in the result. "bob" and "carol" saw both campaigns, so they are excluded.
You can think of it as the union minus the intersection. The diagram for symmetric difference is the two outer regions of the Venn diagram, with the overlap left out:
The middle region is shown in red to mark that it's dropped from the result. Only the two outer regions are kept.
In set-builder language, a ^ b is "everything in a or b but not in both". A useful identity is a ^ b == (a | b) - (a & b), although Python computes it more directly than that.
Unlike difference, symmetric difference is symmetric: swapping the operands gives the same answer.
That's expected: "in exactly one" is a question about the two sets together, not about which side you're starting from.
The method form accepts any iterable and otherwise behaves the same way:
101 was added today, 104 was completed yesterday but not in today's snapshot, and 102 and 103 are in both so they drop out. Symmetric difference is a clean way to ask "what changed between two snapshots?" when the order doesn't matter.
Unlike union, intersection, and difference, the symmetric_difference method only takes one iterable. There's no built-in way to chain it across many sets in a single call, because the multi-way version isn't a standard set operation.
Cost: Symmetric difference is roughly O(len(a) + len(b)). Python has to look at every element on both sides to decide whether it belongs in the result.
We've seen the same pattern four times. Here's the difference in one table:
| Operation | Operator | Method | Accepts non-set iterable? |
|---|---|---|---|
| Union | `a | b` | a.union(b, ...) |
| Intersection | a & b | a.intersection(b, ...) | Method: yes. Operator: no. |
| Difference | a - b | a.difference(b, ...) | Method: yes. Operator: no. |
| Symmetric difference | a ^ b | a.symmetric_difference(b) | Method: yes. Operator: no. |
The operator form is shorter and reads well when both operands are already sets. The method form is what you reach for when one side is a list, tuple, or other iterable, and you don't want to wrap it in set(...) first.
What happens when you try to use an operator with a non-set iterable? You get a clear error:
The fix is either to convert: tags | set(incoming), or to use the method form: tags.union(incoming). The method form is usually nicer because it skips the extra allocation.
The relational operators on sets compare two sets as wholes rather than producing a new set. They answer questions like "is every element of a also in b?".
a <= b is true when a is a subset of b: every element of a is also in b. The method form is a.issubset(b).
Every name in free_users shows up in all_users, so free_users is a subset.
a >= b is the reverse: a is a superset of b, meaning every element of b is also in a. The method form is a.issuperset(b).
Same situation, just viewed from the other side. Subset and superset are mirror images: a <= b is exactly the same as b >= a.
The strict (or proper) variants are < and >. They require the same containment as <= and >= but also forbid equality.
a <= b is True because the two sets are equal (every element of a is in b, even if b adds nothing extra). a < b is False because the two sets are equal, and "proper subset" rules that out. a < c is True because every element of a is in c and c has at least one extra element.
There's no method form for < and >. If you need the proper-subset check, use the operator: a < b is the standard way to say "subset and not equal".
The same operator-vs-method rule we saw with the algebra operations also applies here: the methods issubset and issuperset accept any iterable, but the operators require sets on both sides.
The method handles the list fine. The commented-out operator version would raise the same TypeError we saw earlier.
A useful identity: a == b is equivalent to a <= b and b <= a. Two sets are equal exactly when each is a subset of the other.
Two sets are disjoint if they share no elements. The check is a method, not an operator: a.isdisjoint(b).
No name appears in both groups, so the two sets are disjoint and the method returns True.
If even one element overlaps, the answer flips to False:
"bob" is in both groups, so the sets aren't disjoint.
You could express the same check with intersection: "disjoint" is just "the intersection is empty", and not (a & b) produces the same boolean. isdisjoint is preferred for two reasons. First, it reads as the question you're actually asking. Second, it can short-circuit: as soon as it finds the first shared element, it returns False without scanning the rest. Computing a & b always walks both sets fully even when you only care whether the result is empty.
For large sets where you expect overlap to be common, the short-circuit can matter. For small sets it makes no practical difference, but isdisjoint still reads better.
Like the other methods, isdisjoint accepts any iterable:
No tag from forbidden appears in tags, so the answer is True. There's no equivalent operator form.
Cost: a.isdisjoint(b) is at worst O(len(smaller)) on average, but it often returns much faster because it short-circuits on the first shared element.
Putting the operations together on a single scenario makes the differences click. Suppose an online store ran two marketing campaigns last month:
Each operation answers a different real question about the same two groups. Union is the total reach. Intersection is the most engaged segment, since these people responded to both channels. Difference splits the audience by channel exclusivity. Symmetric difference is the single-touch group, which often deserves a different follow-up than the dual-touch group.
A check at the end: exactly_one equals email_only | sms_only, which is also any_click - both_clicks. Several routes lead to the same answer, and which one reads best depends on which intermediate result you want to name.
10 quizzes