Python Sets and Set Operations

python sets solve a specific, common problem elegantly: you need a collection of unique items, and you need to check membership or compare collections quickly. Lists can technically be forced into this role, but sets are purpose-built for it — and python set operations give you the same union, intersection, and difference logic you might remember from a math class, applied directly to your data.

What is a Set in Python?

A set is an unordered collection of unique items, where every item must be of an immutable type (numbers, strings, tuples — the same requirement covered for dictionary keys in the tuples article).

unique_numbers = {1, 2, 3, 4}

Creating sets

# Curly brace literal syntax
fruits = {"apple", "banana", "cherry"}

# The set() constructor, converting from another iterable
letters = set("hello")
print(letters)   # {'h', 'e', 'l', 'o'} — note: duplicates automatically removed

That second example is worth pausing on: "hello" has five characters, but the resulting set has only four unique ones — the repeated l was automatically collapsed. Duplicate removal isn't something you have to do manually; it's built directly into what a set is.

One quirk worth knowing early: you can't create an empty set with {} — that syntax creates an empty dictionary instead. Use set() for an empty set:

empty_set = set()      # correct
not_a_set = {}          # this is actually an empty dict
Key traits

Two properties define how sets behave, and both matter practically:

  • Unordered — sets don't preserve insertion order the way lists and dictionaries do, and you can't access an item by position (my_set[0] doesn't work — there's no index 0 to speak of).

  • Backed by a hash table — this is why sets exist in the first place. Checking whether an item is in a set is extremely fast, technically described as O(1) — meaning it takes roughly the same amount of time no matter how large the set is. Checking membership in a list, by contrast, is O(n) — Python has to check items one by one, so it gets slower as the list grows. For any code doing repeated membership checks (if x in collection:) against a large collection, this difference is genuinely significant.

Union: Combining Sets

The | operator and union() method

Union combines two sets into a new set containing every unique element from both:

set_a = {1, 2, 3}
set_b = {3, 4, 5}

print(set_a | set_b)          # {1, 2, 3, 4, 5}
print(set_a.union(set_b))     # {1, 2, 3, 4, 5} — identical result

Both approaches produce the same result — | is a compact operator syntax, .union() is the equivalent method, and which one you reach for is mostly a matter of style.

Real-world framing

A natural use case: merging two mailing lists without ending up with duplicate emails.

list_a = {"[email protected]", "[email protected]"}
list_b = {"[email protected]", "[email protected]"}

combined = list_a | list_b
print(combined)
# {'[email protected]', '[email protected]', '[email protected]'}

Even though [email protected] appeared in both original lists, it only appears once in the result — which is exactly the behavior you'd want from a deduplicated merge.

Union is symmetric

A | B produces the same result as B | A — order doesn't matter for union. You can also chain more than two sets together at once:

set_a = {1, 2}
set_b = {2, 3}
set_c = {3, 4}

print(set_a | set_b | set_c)   # {1, 2, 3, 4}

Intersection and Difference

Intersection: what's common to both
pyth {"Alex", "Sam", "Jordan", "Taylor"}
aws_devs = {"Sam", "Jordan", "Casey"}

print(python_devs & aws_devs)                 # {'Sam', 'Jordan'}
print(python_devs.intersection(aws_devs))     # {'Sam', 'Jordan'} — identical result

This is a genuinely practical use case worth noting explicitly: finding candidates who satisfy multiple requirements at once — here, developers who know both Python and AWS — by intersecting two sets of names.

Difference: what's in one but not the other
pyth {"Alex", "Sam", "Jordan", "Taylor"}
aws_devs = {"Sam", "Jordan", "Casey"}

print(python_devs - aws_devs)                # {'Alex', 'Taylor'}
print(python_devs.difference(aws_devs))      # {'Alex', 'Taylor'} — identical result

This finds people who know Python but not AWS — a different, meaningful question from the intersection above.

Difference is not symmetric

Unlike union, order genuinely matters for difference:

print(python_devs - aws_devs)   # {'Alex', 'Taylor'} — in python_devs, not in aws_devs
print(aws_devs - python_devs)   # {'Casey'} — in aws_devs, not in python_devs

A - B and B - A ask two entirely different questions and will generally produce two entirely different results.

A visual way to think about it

If you picture two overlapping circles (a Venn diagram):

  • Union is everything covered by either circle, combined.

  • Intersection is only the overlapping middle region.

  • Difference (A - B) is the part of circle A that doesn't overlap with circle B.

Keeping that mental picture handy makes the distinction between these three operations far easier to remember than the operator symbols alone.

Symmetric Difference and Set Comparisons

Symmetric difference: in either, but not both
set_a = {1, 2, 3}
set_b = {2, 3, 4}

print(set_a ^ set_b)                        # {1, 4}
print(set_a.symmetric_difference(set_b))    # {1, 4} — identical result

Symmetric difference is essentially union minus intersection — everything that's in at least one of the sets, but excluding whatever's shared by both. In the example above, 2 and 3 are in both sets, so they're excluded; 1 and 4 each belong to only one set, so they're included.

issubset() and issuperset()

These check containment relationships rather than combining sets — they answer "is one set entirely contained within another?":

small = {1, 2}
large = {1, 2, 3, 4}

print(small.issubset(large))     # True — every element of small is in large
print(large.issuperset(small))   # True — large contains every element of small

You can also use <= and >= as operator equivalents for issubset() and issuperset() respectively, though the explicit method names tend to read more clearly.

Practical example: comparing config file versions

A genuinely useful real-world pattern: comparing two versions of a configuration file's keys (or any structured data) to see exactly what changed.

old_c {"host", "port", "timeout", "debug"}
new_c {"host", "port", "timeout", "retries", "log_level"}

added = new_config_keys - old_config_keys
removed = old_config_keys - new_config_keys
unchanged = old_config_keys & new_config_keys

print(f"Added: {added}")         # Added: {'retries', 'log_level'}
print(f"Removed: {removed}")     # Removed: {'debug'}
print(f"Unchanged: {unchanged}") # Unchanged: {'host', 'port', 'timeout'}

This single pattern — difference in both directions plus intersection — is a compact, readable way to summarize exactly what changed between two versions of anything represented as a set.

Modifying Sets and frozenset

Mutating methods
fruits = {"apple", "banana"}

# add() — adds a single item
fruits.add("cherry")
print(fruits)   # {'apple', 'banana', 'cherry'}

# update() — adds multiple items from another iterable
fruits.update(["date", "fig"])
print(fruits)   # {'apple', 'banana', 'cherry', 'date', 'fig'}
remove() vs. discard()

Both remove a specific item, but they behave differently when that item isn't present:

fruits = {"apple", "banana"}

fruits.remove("banana")    # removes it, no error
fruits.remove("grape")
# KeyError: 'grape' — remove() raises an error if the item isn't found

fruits.discard("grape")    # does nothing, no error — discard() is safe either way

Use remove() when you expect the item to definitely be present and want an error if that assumption turns out to be wrong. Use discard() when you just want the item gone if it exists, and don't care whether it was there in the first place.

frozenset: the immutable variant

frozenset is exactly what it sounds like — a set that can't be modified after creation, which (echoing the same logic covered for tuples) makes it hashable and therefore usable as a dictionary key or as an element inside another set:

team_a = frozenset(["Alex", "Sam"])
team_b = frozenset(["Jordan", "Casey"])

matchups = {team_a: "Group 1", team_b: "Group 2"}
print(matchups[team_a])   # Group 1

A regular, mutable set can't be used as a dictionary key for the same reason a list can't — its contents could change after being used as a key, which would break the dictionary's internal consistency. frozenset sidesteps that entirely by removing the ability to change it in the first place, making it a natural fit for fixed collections like a team roster or a locked-in configuration.

Set comprehensions

Sets support a comprehension syntax mirroring list comprehensions, letting you build a set concisely from an expression and a condition:

even_numbers = {n for n in range(10) if n % 2 == 0}
print(even_numbers)   # {0, 2, 4, 6, 8}

This produces a set directly, with duplicate results (if any arose from the expression) automatically collapsed — the same deduplication behavior as any other set.

Wrap-up: when sets are the right tool

Sets are the right choice specifically for deduplication, fast membership testing, and comparing collections against each other — union, intersection, difference, and the containment checks covered above. They're deliberately not the right tool when order matters or when you need to access items by position — for those situations, a list (covered earlier in this series) remains the better fit.