# Create a set
numbers = {1, 2, 3, 4, 5}
empty_set = set() # Not {}, that's a dictionary
unique_items = set([1, 2, 2, 3, 3, 4]) # {1, 2, 3, 4}
Sets are Python’s built-in data structure for storing unique, unordered collections.
They automatically eliminate duplicates and provide fast membership testing. If you’ve ever needed to remove duplicate entries from a list or check whether something exists in a collection without iterating through every item, sets solve that problem.
Creating and initializing Python sets
You can create Python sets in three ways. The curly brace syntax works for non-empty sets, while the set() constructor handles empty sets and conversions from other iterables.
# Direct creation
fruits = {'apple', 'banana', 'cherry'}
# From a list (duplicates removed automatically)
numbers = set([1, 2, 2, 3, 3, 3, 4])
print(numbers) # {1, 2, 3, 4}
# From a string (each character becomes an element)
letters = set('hello')
print(letters) # {'h', 'e', 'l', 'o'}
# Empty set (careful here)
empty = set() # Correct
not_empty = {} # This creates a dictionary, not a set
The gotcha that trips up newcomers is the empty set syntax. You can’t use {} because Python reserves that for dictionaries. This makes sense once you know that sets came later to the language than dictionaries, so the bracket notation was already taken.
Basic operations with Python sets that actually matter
Sets shine when you need to add, remove, or check for items. The operations are straightforward and perform better than equivalent list operations.
animals = {'cat', 'dog', 'bird'}
# Adding items
animals.add('fish')
print(animals) # {'cat', 'dog', 'bird', 'fish'}
# Adding won't duplicate
animals.add('cat')
print(animals) # Still {'cat', 'dog', 'bird', 'fish'}
# Removing items (raises error if not found)
animals.remove('dog')
# Safer removal (no error if missing)
animals.discard('elephant') # Does nothing, no error
# Remove and return arbitrary item
random_animal = animals.pop()
The difference between remove() and discard() matters in production code. If you’re not certain an item exists, discard() saves you from handling exceptions. I’ve seen codebases littered with try-except blocks around remove() calls when discard() would have been cleaner.
Membership testing is why Python sets exist
This is where sets actually earn their keep. Checking if an item exists in a set is constant time, O(1), while lists require iterating through items, O(n).
# Slow with lists
items_list = list(range(10000))
print(9999 in items_list) # Has to check every item
# Fast with sets
items_set = set(range(10000))
print(9999 in items_set) # Direct lookup, instant
The performance difference isn’t academic. If you’re checking membership repeatedly in a loop, using a list instead of a set can turn a millisecond operation into minutes. I’ve optimized code from 30 seconds to under a second just by converting a membership check from a list to a set.
Python set mathematics for practical problems
Sets implement mathematical operations that solve real problems. Union combines sets, intersection finds common elements, difference shows what’s unique to one set.
developers = {'Alice', 'Bob', 'Charlie'}
designers = {'Bob', 'Diana', 'Eve'}
# Union: everyone on both teams
all_people = developers | designers
print(all_people) # {'Alice', 'Bob', 'Charlie', 'Diana', 'Eve'}
# Intersection: people on both teams
both_teams = developers & designers
print(both_teams) # {'Bob'}
# Difference: only developers
only_devs = developers - designers
print(only_devs) # {'Alice', 'Charlie'}
# Symmetric difference: people on exactly one team
one_team_only = developers ^ designers
print(one_team_only) # {'Alice', 'Charlie', 'Diana', 'Eve'}
These operations have method equivalents that read more clearly in some contexts.
# Method versions of the same operations
all_people = developers.union(designers)
both_teams = developers.intersection(designers)
only_devs = developers.difference(designers)
one_team_only = developers.symmetric_difference(designers)
The method versions accept any iterable as an argument, not just sets. That flexibility helps when you’re working with mixed data types.
numbers = {1, 2, 3}
# This works
result = numbers.union([4, 5, 6])
# This doesn't
result = numbers | [4, 5, 6] # TypeError
Practical use cases for Python sets that come up constantly
Sets solve specific problems better than any other data structure. Removing duplicates from user input, finding common elements between datasets, or tracking unique visitors all become simple with sets.
# Remove duplicates from a list
user_ids = [101, 102, 101, 103, 102, 104]
unique_ids = list(set(user_ids))
print(unique_ids) # [101, 102, 103, 104]
# Find common interests
user_a_interests = {'python', 'golang', 'rust', 'javascript'}
user_b_interests = {'python', 'java', 'javascript', 'c++'}
shared_interests = user_a_interests & user_b_interests
print(shared_interests) # {'python', 'javascript'}
# Track unique visitors
visitors = set()
visitors.add('user_123')
visitors.add('user_456')
visitors.add('user_123') # Duplicate, ignored
print(len(visitors)) # 2
One pattern I use constantly is filtering a large dataset based on a smaller set of valid identifiers. Converting the identifier list to a set makes the filtering operation dramatically faster.
# Slow approach with lists
valid_ids = [1, 2, 3, 4, 5]
records = [{'id': i, 'data': 'value'} for i in range(1000)]
filtered = [r for r in records if r['id'] in valid_ids]
# Fast approach with sets
valid_ids_set = {1, 2, 3, 4, 5}
records = [{'id': i, 'data': 'value'} for i in range(1000)]
filtered = [r for r in records if r['id'] in valid_ids_set]
Updating Python sets in place
Sets provide methods that modify the set directly rather than creating new ones. These operations are faster when you’re working with large datasets and don’t need to preserve the original.
tags = {'python', 'programming'}
# Add multiple items
tags.update(['web', 'backend', 'api'])
print(tags) # {'python', 'programming', 'web', 'backend', 'api'}
# Intersection update (keep only common elements)
allowed_tags = {'python', 'web', 'mobile'}
tags.intersection_update(allowed_tags)
print(tags) # {'python', 'web'}
# Difference update (remove elements found in another set)
tags = {'python', 'web', 'mobile', 'backend'}
deprecated = {'mobile', 'backend'}
tags.difference_update(deprecated)
print(tags) # {'python', 'web'}
The naming convention helps clarify what’s happening. Methods ending in _update modify the set in place, while their counterparts without the suffix return new sets.
Immutable Python sets with frozenset
Sometimes you need a set that can’t change. Frozen sets work as dictionary keys or elements in other sets, which regular sets can’t do because they’re mutable.
# Create a frozen set
immutable = frozenset([1, 2, 3])
# Can't modify it
# immutable.add(4) # AttributeError
# Can use as dictionary key
cache = {}
key = frozenset(['python', 'tutorial'])
cache[key] = 'cached_result'
# Can nest in other sets
set_of_sets = {frozenset([1, 2]), frozenset([3, 4])}
print(set_of_sets) # {frozenset({1, 2}), frozenset({3, 4})}
Frozen sets come up less often than regular sets, but they’re essential when you need hashable collections. Configuration data that shouldn’t change or building composite cache keys are the main use cases.
Comprehensions for building Python sets
Set comprehensions follow the same syntax as list comprehensions but produce sets with automatic deduplication.
# Create a set of squared numbers
squares = {x**2 for x in range(10)}
print(squares) # {0, 1, 4, 9, 16, 25, 36, 49, 64, 81}
# Extract unique words from text
text = "the quick brown fox jumps over the lazy dog"
unique_words = {word for word in text.split()}
print(unique_words) # {'the', 'quick', 'brown', 'fox', 'jumps', 'over', 'lazy', 'dog'}
# Filter and transform in one operation
numbers = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10}
even_squares = {x**2 for x in numbers if x % 2 == 0}
print(even_squares) # {4, 16, 36, 64, 100}
The deduplication happens automatically, which makes set comprehensions perfect for extracting unique transformed values from larger datasets.
Performance characteristics that matter
Sets use hash tables internally, which gives them constant time operations for adding, removing, and checking membership. That’s the whole reason they exist. Lists can’t match that performance for these operations.
import time
# Compare membership testing
large_list = list(range(100000))
large_set = set(range(100000))
# Test with list
start = time.time()
for _ in range(1000):
99999 in large_list
list_time = time.time() - start
# Test with set
start = time.time()
for _ in range(1000):
99999 in large_set
set_time = time.time() - start
print(f"List: {list_time:.4f}s")
print(f"Set: {set_time:.4f}s")
The tradeoff is memory. Sets consume more memory per element than lists because of the hash table overhead. For small collections, the difference doesn’t matter. For millions of items, it adds up.
When Python sets aren’t the answer
Sets lose ordering information. If you need to maintain the sequence of items, sets will frustrate you because they’re fundamentally unordered collections.
numbers = {5, 1, 3, 2, 4}
print(numbers) # Order is unpredictable
Sets also only work with hashable types. You can’t store lists, dictionaries, or other sets as set elements because these types are mutable and therefore not hashable.
# This fails
bad_set = {[1, 2, 3]} # TypeError: unhashable type: 'list'
# This works
good_set = {(1, 2, 3)} # Tuples are hashable
If you need both uniqueness and ordering, you have two options. Use a list and manually check for duplicates, or use Python 3.7+ dictionaries which maintain insertion order and can simulate sets through their keys.
# Ordered unique collection
ordered_unique = list(dict.fromkeys([3, 1, 2, 1, 3, 2]))
print(ordered_unique) # [3, 1, 2]
Sets are a specialized tool that excel at specific tasks. Understanding when to reach for them versus lists or dictionaries separates developers who write slow code from those who write fast code.

