Sets and Set Operations

A collection with no duplicates and no order, built for membership tests and for comparing two groups.

Overview

What a set gives up, and what it buys

Write a set with braces:

seen = {"red", "green", "red", "blue"}

Three items come out, not four: duplicates collapse silently. Print it twice and the order may differ, because there is no order to preserve.

In exchange, x in some_set is roughly constant time. On a list, the same question is a scan: Python looks at each element until it finds a match or runs out. On a few dozen items nobody notices. On two hundred thousand, the difference is milliseconds against microseconds, and the second program on this page measures it rather than asserting it.

sets.py

sets.py Python 3
Output

                    

set_algebra.py

set_algebra.py Python 3
Output

                    

Worth knowing

{} is an empty dict, not an empty set. Use set().
No duplicates and no order. If order matters, a set is the wrong container.
& intersection, | union, - difference, ^ in one but not both.
Membership is roughly constant time on a set and a full scan on a list. That gap is the whole reason to convert.

Sets and Set Operations: A Practical Guide

A set holds unique items in no particular order. That trade — giving up order and duplicates — buys near-instant membership tests and a small algebra for comparing two groups.

Deduplicating

The most common use is removing duplicates:

unique = set(votes)

If you need a list back, wrap it: list(set(votes)). If you need the original order kept, a set will not do it — use list(dict.fromkeys(votes)), since dictionaries do preserve insertion order.

The algebra

Four operators compare two sets, and they read like the English:

python & sql  # in both
python | sql  # in either
python - sql  # in the first only
python ^ sql  # in exactly one

Each replaces a loop with an if inside it. "Which users have both skills" is one character. Writing it as a loop is not wrong, it is just more code to read and more places to make a mistake.

The empty-set trap

{} is an empty dictionary. It has to be, because dictionaries claimed the braces first. An empty set is set(). This is worth remembering because {} looks exactly like what you want and behaves nothing like it.

When not to use one

If order matters, if duplicates are meaningful, or if you need to index by position, use a list. A set answers one question extremely well — "is this in here?" — and refuses most others.

Order, and why you cannot rely on it

A set has no order. Printing one twice in the same run usually gives the same arrangement, which tempts people into depending on it, and that arrangement can change between runs or between versions. If you need a stable order, sort on the way out:

for name in sorted(unique_names):
    ...

If you need to keep the order things first appeared, a set is the wrong tool:

list(dict.fromkeys(items))     # de-duplicated, original order kept

Dictionaries preserve insertion order, so this is the standard trick for "unique but in the order I saw them".

Modifying a set

seen.add("x")          # one item
seen.update(others)    # many
seen.discard("x")      # remove, no error if absent
seen.remove("x")       # remove, KeyError if absent

discard and remove differ only in what happens when the item is not there, and choosing between them is a small design decision: remove says "this should have been present", discard says "get rid of it if it is".

The operators have in-place forms too: a |= b adds everything from b, a &= b keeps only what both share.

A frozen set, for when a set needs to be a key

Sets are mutable, so a set cannot be a dictionary key or a member of another set. frozenset is the immutable version:

groups = {frozenset({"ana", "bo"}): "pair one"}

This comes up when the key is genuinely a collection - a set of tags, a group of users - and you need to look something up by it.

The cost, concretely

Building a set from a list is one pass, so converting is cheap. The win comes when you test membership more than once. Converting a list to a set to do a single lookup is slower than just scanning the list; converting once and then testing thousands of times is the case the second program on this page measures, and there the difference is not subtle.

What "hashable" is really asking

A set decides where to store an item by computing its hash, so the hash has to stay the same for as long as the item is in the set. That is the entire reason lists and dictionaries cannot be set members: they can change, the hash would change with them, and the set would be looking in the wrong place for something it definitely contains.

This is not a rule Python invented to be awkward. It falls straight out of how the lookup works. The same constraint applies to dictionary keys, which is why the two rules are always taught together, and why the error message mentions hashability rather than mutability.

Strings, numbers, booleans, None and tuples of those are all hashable and can go in a set. Anything you can mutate cannot.

Sets in everyday code

Three patterns cover most real uses.

Deduplication is the obvious one: set(values) collapses repeats in a single pass, and wrapping it in sorted() gives back a predictable order.

Membership testing is the important one. Any time a loop contains if x in something, ask what something is. If it is a list that does not change during the loop, converting it to a set once before the loop turns a scan into a lookup, and that single change is often the entire fix for a slow function.

Comparing two collections is the one people forget exists. "Which users are in both groups", "which required fields are missing", "which files changed" are all one operator rather than a loop with an if inside it, and the operator version is far harder to get subtly wrong.

Comparing two collections, concretely

The operators turn four common questions into four characters:

a = {"ana", "bo", "cy"}
b = {"bo", "cy", "di"}

print(sorted(a & b))
print(sorted(a - b))
print(sorted(a ^ b))
print(a <= b, {"bo"} <= a, a.isdisjoint({"zz"}))
['bo', 'cy']
['ana']
['ana', 'di']
False True True

& is "in both", - is "in the first only", ^ is "in exactly one". Each replaces a loop containing an if, and each is far harder to get subtly wrong than the loop would be — there is no index, no accumulator, and no chance of testing the wrong direction.

sorted() around each result is doing real work: a set has no order, so printing one directly gives an arrangement you must not depend on. Sorting on the way out is how you get a stable, readable result.

The last line shows the comparison operators. <= is "is a subset of", so {"bo"} <= a asks whether every member of the left is in the right. >= is the superset direction, and isdisjoint asks whether two sets share nothing — which is cheaper than building the intersection just to see if it is empty.

The questions each operator answers

The algebra is worth reading as English, because that is how you will recognise which one you want.

"Which do they have in common?" is a & b. Shared tags, users in both groups, fields present in two records.

"What is in the new one that was not in the old one?" is new - old. Added files, new permissions, keys that appeared. Reverse the operands for what was removed, and note that this is the operator people most often get the wrong way round.

"What changed either way?" is a ^ b. It is the union of both differences, and it is the right answer for "what is not the same", which is usually what a diff wants.

"Is everything required actually present?" is required <= provided, or equivalently required - provided being empty — and the second version is more useful in practice because it tells you *which* are missing rather than just that some are.

"Do these overlap at all?" is a.isdisjoint(b), which stops at the first shared item rather than computing the whole intersection.

All five work with the method forms too — intersection, difference, symmetric_difference, issubset — and the methods accept any iterable, where the operators require both sides to be sets. a & [1, 2] raises; a.intersection([1, 2]) does not.

Where a set fits among the collections

Choosing a container is choosing what you give up, and stating it as a table makes the decision quick.

A list keeps order and duplicates and allows indexing; membership testing scans. A tuple is the same and immutable, so it can be a key. A set has no order, no duplicates and no indexing; membership is constant time. A dictionary is a set of keys with a value attached to each, and it inherits the set's lookup speed for keys.

The decision usually comes down to one question: what will you ask this collection most often? If the answer is "give me item three" or "what order did these arrive in", it is a list. If the answer is "is this one in here", it is a set. If the answer is "what is the value for this name", it is a dictionary.

The case worth noticing is a list that is only ever asked "is this in here?". That is a set wearing the wrong type, and converting it once before a loop changes a scan per iteration into a lookup per iteration. It is one of the very few performance changes that is both trivial to make and frequently decisive.

The reverse mistake is reaching for a set when duplicates carry meaning. Counting votes, recording events, keeping a history — the duplicates *are* the data, and a set silently deletes them.

Building one efficiently

Three ways to get a set, and the differences matter once the input is large.

set(iterable) is the direct conversion and the one to use when you already have the items. It is a single pass, and it works on any iterable including a generator, so set(line.strip() for line in f) never builds the intermediate list.

A set comprehension, {f(x) for x in items}, builds and deduplicates in the same pass. This is better than set([f(x) for x in items]), which constructs the whole list first and then throws it away — the same waste as wrapping any comprehension in a converter.

Adding in a loop with add is right when the items arrive one at a time or the loop is doing something else as well. update takes an iterable and adds all of it, so a loop that only calls add is usually one update in disguise.

The one to avoid is repeatedly rebuilding: seen = seen | {x} inside a loop creates a whole new set on every iteration, which turns a linear job into a quadratic one. seen.add(x) modifies in place, and seen |= other does the same for a batch.

Questions people ask

Why is {} an empty dictionary? Dictionaries had the braces first. set() is the only way to write an empty set.

Can a set contain a list? No. Members must be hashable, and lists can change. Use a tuple, or a frozenset for a set of sets.

Does set() preserve order? No. Use list(dict.fromkeys(items)) to deduplicate while keeping first-seen order.

Is x in a_set really constant time? On average, yes. It hashes once and looks in one place, regardless of size.

Can I sort a set? sorted(s) returns a list. A set itself has no order to arrange.

What is the difference between remove and discard? remove raises KeyError when the item is absent; discard does not.

Are 1 and True different set members? No. They hash the same and compare equal, so {1, True} has one element.

Can I use a set as a dictionary value? Yes. Only keys need to be hashable; values can be anything.

How do I find duplicates rather than remove them? Compare the lengths, or use collections.Counter and keep the entries with a count above one. A set alone tells you they existed, not which.

Is a set faster than a dictionary for membership? They use the same mechanism, so effectively no. Use a dictionary when you also need a value attached.

Recap in one screen

  • A set gives up order and duplicates and buys constant-time membership tests.
  • &, |, - and ^ answer "both", "either", "only the first" and "exactly one" without a loop.
  • {} is an empty dictionary; set() is the empty set.
  • Members must be hashable, for the same reason dictionary keys must be: the hash decides where the item is stored.
  • A list that is only ever asked "is this in here?" wants to be a set, and the conversion is often the whole fix for a slow loop.

Check yourself

0 of 3

Answer without scrolling back up.

  1. What does `{}` create?

  2. Why is `x in big_set` so much faster than `x in big_list`?

  3. `{"a", "b"} ^ {"b", "c"}` gives what?

Cheat sheet

Sets and Set Operations

A set holds unique items in no particular order. That trade — giving up order and duplicates — buys near-instant membership tests and a small algebra for comparing two groups.

PYTHON · vizlearn.in/python/sets_and_set_operations.html

About the author

Ashish Jangra builds and maintains VizLearn. Every module here is written and the visualisation behind it hand-built, so the numbers in a readout come from the same code that draws the picture. Corrections are genuinely welcome and get priority over everything else — if a page states something wrong, or an animation misrepresents what the algorithm does, get in touch.