You've written what seems like perfectly reasonable code. Python slaps you with TypeError: 'set' object is not subscriptable. Not a KeyError. Not an IndexError. A TypeError — Python is telling you that the operation itself is conceptually invalid, not that you picked a bad index.
The set type doesn't just lack a particular element at position zero. It lacks the concept of position entirely. Understanding why requires looking at three things that tutorials rarely show you: what's actually happening in memory when Python stores a set, what the word "ordered" means at the language design level, and why Python's type hierarchy formally distinguishes containers that have positions from containers that don't.
colors = {"red", "green", "blue"}
print(colors[0])
# TypeError: 'set' object is not subscriptable
What a Set Actually Is in Memory
A Python set is a hash table. Not metaphorically. Not "kind of like" a hash table. The CPython source file setobject.c, written and maintained by Raymond Hettinger, opens with a header stating that the implementation derives from Lib/sets.py and Objects/dictobject.c and that the core lookup algorithm follows Algorithm D from Knuth's The Art of Computer Programming, Volume 3, Section 6.4 (source: CPython source, Objects/setobject.c).
Here's what that means concretely. When you write:
colors = {"red", "green", "blue"}
Python does not store "red" at position 0, "green" at position 1, and "blue" at position 2. Instead, Python computes the hash of each string, then uses that hash value to determine a slot in an internal array. The hash code determines a storage location, not a sequential position.
Which slot each element lands in depends on three things: the element's hash value, the current size of the internal table, and the per-process random hash seed. This seed has been enabled by default since Python 3.3 as a security measure against hash collision denial-of-service attacks (source: Python docs, PYTHONHASHSEED). The randomization was first introduced in Python 3.2.3 as an opt-in feature via PYTHONHASHSEED before becoming the default behavior. The result is that "red" might end up in slot 5, "green" in slot 2, and "blue" in slot 7 — with the remaining slots empty.
There is no slot 0 containing the "first" element. The internal array is sparse: occupied slots are scattered according to hash arithmetic, and empty slots sit between them. This is the fundamental reason sets don't support indexing. Indexing implies a positional relationship — "give me the element at position n." A hash table has no positions. It has buckets.
Modern CPython's set implementation also uses a hybrid probing strategy. After checking a few consecutive nearby entries (linear probing, which is friendly to CPU cache lines), it falls back to a more scattered pattern using bits from the upper portion of the hash value. This design, documented in the setobject.c source comments, represents a deliberate trade-off: it optimizes for fast lookups and insertions rather than maintaining any element ordering.
Why Not Just Fake It?
A reasonable follow-up question: even if the internal storage isn't ordered, couldn't Python just pretend it is? When you ask for colors[0], couldn't it just hand you the first element it finds by scanning the internal table?
It could. Python chose not to, and the reason is that this would be a dangerous lie.
Consider what happens with a list:
fruits = ["apple", "banana", "cherry"]
print(fruits[0]) # "apple" --- always, every time, guaranteed
Indexing into a list is a contract. It promises that fruits[0] will always return "apple" until you explicitly change the list. Now imagine sets offered fake indexing:
colors = {"red", "green", "blue"}
print(colors[0]) # "green" today? "blue" tomorrow?
The "first" element would depend on hash values, which depend on a random seed that changes every time you restart Python. Your code would produce different results on different runs. Even more treacherously, adding or removing a single element can cause the entire internal table to resize and rehash, rearranging where every element sits.
This is not a hypothetical concern. Before hash randomization became the default, developers occasionally wrote code that relied on the apparently stable iteration order of sets with small integers (whose hash values equal themselves in CPython). That code would then break mysteriously when deployed to different Python versions or different machines. The absence of fake indexing prevents this entire class of bugs from existing in the first place.
PEP 20, authored by Tim Peters, includes two principles that apply directly here: "Errors should never pass silently" and "In the face of ambiguity, refuse the temptation to guess." An indexing operation on a set would be inherently ambiguous — there's no stable, meaningful answer to "what's at position 0?" — so Python refuses to guess. The text was written in 1999 and formalized as a PEP in 2004 (source: PEP 20).
The Dict Precedent: Ordered but Not Subscriptable
If you know that dictionaries have been insertion-ordered since Python 3.7 (a CPython implementation detail since 3.6, formalized as a language guarantee in 3.7), you might wonder: why didn't sets get the same treatment?
It's a fair question, and the Python core developers discussed it explicitly. When CPython 3.6 introduced the compact dictionary implementation (proposed by Raymond Hettinger and implemented by Inada Naoki), dictionaries became ordered as a side effect of the new memory layout. The new design uses a dense entries array that stores keys, values, and hashes in insertion order, with a separate sparse index table.
For sets, the calculus is different. In December 2017 (and reiterated in python-dev discussions in 2019), Raymond Hettinger explained that sets use a fundamentally different algorithm that doesn't lend itself to preserving insertion order. He noted that set-to-set operations would lose flexibility and optimizations if ordering were required, and that set mathematics are defined in terms of unordered collections (source: python-dev mailing list archive; Stack Overflow summary).
The technical reasoning is specific. The compact dict works by keeping two arrays: a sparse hash table of small indices, and a dense array of (hash, key, value) triples. Sets only have two fields (hash and key — there are no values), so the space savings from compaction are smaller. Meanwhile, the cost of maintaining insertion order during set operations like intersection, union, and symmetric difference would be substantial. When you compute set_a & set_b, the result would need to respect — whose insertion order? The question doesn't have a clean answer.
Tim Peters elaborated on the python-dev list that sets mix additions and deletions far more frequently than dicts do, and the compact dict's approach of filling holes left by deletions would require periodic massive internal rearrangement for sets, even when the total size barely changes (source: python-dev thread, 2019).
Even though dictionaries are now ordered, they still don't support integer indexing. You cannot write my_dict[0] to get the first key-value pair — that syntax already means "look up the key 0." Ordered storage is an implementation property, while indexing is a semantic contract about position.
The __getitem__ Protocol: What Indexing Actually Means
When you write obj[0], Python calls obj.__getitem__(0). This is the subscript protocol, and it's at the heart of what makes a type "indexable."
Lists implement __getitem__. Tuples implement __getitem__. Strings implement __getitem__. Sets do not. This isn't an oversight — it's a deliberate declaration that subscript access is not a meaningful operation for sets.
Python formalizes this distinction through Abstract Base Classes, defined in PEP 3119 (authored by Guido van Rossum and Talin, 2007). The collections.abc module defines separate ABCs for Sequence and Set, each with fundamentally different required methods:
collections.abc.Sequence requires:
__getitem__(index)— access by position__len__()— size of the sequence
collections.abc.Set requires:
__contains__(element)— membership testing__iter__()— iteration__len__()— size of the set
Notice what's missing from Set: there's no __getitem__. The ABC for sets doesn't include positional access because positional access is not part of what it means to be a set. This isn't a Python quirk — it's a reflection of how sets work in mathematics, where the set {1, 2, 3} and the set {3, 1, 2} are the same set. Asking "what's the first element?" of a mathematical set is a category error.
You can verify this yourself in the REPL:
from collections.abc import Sequence, Set
isinstance([1, 2, 3], Sequence) # True
isinstance({1, 2, 3}, Sequence) # False
isinstance({1, 2, 3}, Set) # True
hasattr({1, 2, 3}, '__getitem__') # False
This type-level distinction is also what makes tools like mypy and other static analyzers able to catch set-indexing bugs before your code even runs.
The Python Built-in Types documentation describes sets as unordered collections that do not record element position or insertion order, and therefore do not support indexing, slicing, or other sequence-like behavior (source: Python docs, Set Types).
What a Set Can Do (and Why It's Better)
If sets can't be indexed, what are they actually good for? Everything that indexing isn't.
Membership testing in O(1)
allowed = {"admin", "editor", "viewer"}
if user_role in allowed:
grant_access()
That in check runs in constant time regardless of set size. The same check on a list is O(n) — it has to scan every element. For a set with a million entries, the difference is the gap between one hash computation and a million comparisons.
Duplicate elimination
raw_tags = ["python", "code", "python", "tutorial", "code"]
unique_tags = set(raw_tags)
# {'python', 'code', 'tutorial'}
Set algebra — union, intersection, difference, symmetric difference
backend = {"alice", "bob", "carol"}
frontend = {"bob", "dave", "eve"}
# Who works on both?
both = backend & frontend # {'bob'}
# Everyone on either team?
everyone = backend | frontend # {'alice', 'bob', 'carol', 'dave', 'eve'}
# Backend only?
backend_only = backend - frontend # {'alice', 'carol'}
# On exactly one team?
exclusive = backend ^ frontend # {'alice', 'carol', 'dave', 'eve'}
These operations are what PEP 218 was designed for. That PEP, co-authored by Greg Wilson and Raymond Hettinger, proposed adding a built-in set type to Python. It was first implemented as the sets module in Python 2.3, then promoted to the built-in set and frozenset types in Python 2.4 (source: PEP 218).
The operator choices were deliberate. During the PEP 218 discussion, the authors considered using + for union but noted that Guido van Rossum pointed out + already carries expectations of symmetry from other built-in types. They chose bitwise operators (|, &, ^) because these naturally signal a different kind of container — one that operates on membership, not position (source: PEP 218, Proposal section).
frozenset: Immutable but Still Not Indexable
A question that sometimes follows: if sets are mutable and that causes ordering trouble, what about frozenset? A frozen set can't be modified after creation, so couldn't Python lock in a stable order and support indexing?
fs = frozenset(["red", "green", "blue"])
fs[0] # TypeError: 'frozenset' object is not subscriptable
The answer is no, and for two reasons. First, even though a frozenset is immutable, its internal storage still uses hash-determined slot placement. The element ordering you see during iteration is still governed by hash values and the per-process random seed, meaning a frozenset created in two different Python processes may iterate in different orders. Second, and more fundamentally, frozenset shares the same Abstract Base Class as set — both are instances of collections.abc.Set, not collections.abc.Sequence. Immutability doesn't change the mathematical identity of the container.
The frozenset type exists for a different purpose: to make sets usable as dictionary keys and as elements of other sets (since dictionary keys and set elements must be hashable, and mutable sets are not).
# frozensets can be set members; regular sets cannot
valid = {frozenset({1, 2}), frozenset({3, 4})}
invalid = {{1, 2}, {3, 4}} # TypeError: unhashable type: 'set'
The Workarounds (and When to Actually Use Them)
Sometimes you genuinely need to get "an element" from a set, or you need indexed access to what started as a set. Here are your options, ranked by how appropriate they are.
Get an arbitrary element: next(iter(s))
colors = {"red", "green", "blue"}
one_color = next(iter(colors)) # Gets one element without removing it
This is the idiomatic Python way to peek at a set element. It doesn't guarantee which element you get, and that's the point — if you need a specific element, you shouldn't be using a set. If you need an element and don't mind losing it from the set, set.pop() is O(1) on average, but it mutates the set.
Calling next(iter(s)) on an empty set raises StopIteration, which can be confusing if you're not expecting it. Use next(iter(s), None) to return a default value instead, or guard with an if s: check first. Similarly, set.pop() on an empty set raises KeyError, not IndexError — because sets deal in keys, not positions.
Convert to a list when you need positions
colors = {"red", "green", "blue"}
color_list = sorted(colors) # ['blue', 'green', 'red']
print(color_list[0]) # 'blue' --- stable, predictable
Note the sorted() call rather than list(). Converting a set to a list with list(colors) gives you an unpredictable order. If you need indexing, you almost certainly also need a defined order, and that means sorting. If the elements aren't naturally sortable (mixed types, custom objects), provide a key function to sorted().
Use an ordered set when you need both uniqueness and position
Python's standard library doesn't include an ordered set type, but dict.fromkeys() is a common workaround since dictionaries maintain insertion order (guaranteed since Python 3.7):
items = ["banana", "apple", "banana", "cherry", "apple"]
unique_ordered = list(dict.fromkeys(items))
# ['banana', 'apple', 'cherry'] --- insertion order preserved, duplicates removed
For a full-featured ordered set, the third-party ordered-set package (available on PyPI) provides a class that supports both set operations and indexing. It traces its lineage to an OrderedSet recipe originally written by Raymond Hettinger (source: PyPI, ordered-set). As of 2026, there have been recurring discussions on python-ideas and python-dev about adding an OrderedSet to the standard library's collections module, but no PEP has been accepted for it (source: LWN.net, "An ordered set for Python?").
Use a list from the start if ordering is your primary need
If you find yourself converting sets to lists every time you use them, that's a signal you chose the wrong data structure. Lists exist precisely for ordered, indexable collections. You can always deduplicate a list after the fact with list(dict.fromkeys(items)) without ever touching a set.
Debugging the TypeError in Real Code
The TypeError: 'set' object is not subscriptable error doesn't always come from obvious places. Here are three patterns where it catches people off guard.
Accidental set literal vs. dict literal
# This creates a set, not a dict:
config = {"debug", "verbose", "strict"}
# config["debug"] # TypeError!
# This creates a dict:
config = {"debug": True, "verbose": False, "strict": True}
config["debug"] # True
The visual difference between {"a", "b"} (set) and {"a": 1, "b": 2} (dict) is subtle, especially in long lines or when reviewing unfamiliar code. If you get this error on a line you thought was a dict, check for missing colons and values.
Functions that return sets unexpectedly
# dict.keys() returned a list in Python 2 (subscriptable),
# but returns a dict_keys view in Python 3 (not subscriptable)
d = {"x": 1, "y": 2}
keys = d.keys()
# keys[0] # TypeError: 'dict_keys' object is not subscriptable
# Fix: wrap in list() if you need indexing
first_key = list(d.keys())[0] # or next(iter(d))
Comprehension type confusion
# Set comprehension (curly braces, no colon):
result = {x**2 for x in range(5)} # {0, 1, 4, 9, 16} --- a set
# List comprehension (square brackets):
result = [x**2 for x in range(5)] # [0, 1, 4, 9, 16] --- a list
# Dict comprehension (curly braces WITH colon):
result = {x: x**2 for x in range(5)} # {0: 0, 1: 1, ...} --- a dict
The bracket shape is the only syntactic difference. If you want to index the result, use square brackets for a list comprehension.
The Performance Story
Here's the real trade-off you're making when you choose between a list and a set:
| Operation | list | set |
|---|---|---|
| Access by index | O(1) | Not supported |
| Search for element | O(n) | O(1) average |
| Insert at end | O(1) amortized | O(1) average |
| Insert at beginning | O(n) | O(1) average |
| Remove by value | O(n) | O(1) average |
| Check membership | O(n) | O(1) average |
| Maintain duplicates | Yes | No |
| Maintain order | Yes | No |
The word "average" next to every set operation is doing important work. In the worst case — when many elements hash to the same slot — set operations degrade to O(n). This is precisely the scenario that hash randomization was introduced to prevent attackers from triggering deliberately. In practice, with a good hash function and a reasonably sized table, collisions are rare enough that O(1) average-case performance holds.
The set's inability to index is not a limitation. It's the cost of O(1) membership testing. A list achieves O(1) indexing by storing elements in contiguous memory at fixed positions — which means finding a specific value requires scanning the entire list. A set achieves O(1) membership testing by storing elements at hash-determined locations — which means there are no fixed positions to index into.
You can't have both for free. The data structures that offer both (like sorted containers backed by balanced trees) pay for it with O(log n) performance on both operations, plus significant memory overhead. Python's sortedcontainers third-party library (by Grant Jenks) offers a SortedSet that supports indexing in O(log n) — but that's a deliberate trade-off, not a free lunch.
The Abstract Data Type Perspective
When the TypeError says a set is "not subscriptable," it's enforcing a boundary between two fundamentally different kinds of containers — what computer science calls abstract data types (ADTs).
A Sequence is an ADT defined by position. Its core operations are "retrieve by index" and "determine length." Lists, tuples, and strings are all sequences. Their identity depends on both the elements they contain and the order of those elements: [1, 2, 3] is not the same list as [3, 2, 1].
A Set is an ADT defined by membership. Its core operations are "test whether an element is present," "iterate over elements," and "determine size." Its identity depends only on which elements it contains: {1, 2, 3} is exactly the same set as {3, 2, 1}.
This isn't just a Python convention. It traces directly to the mathematical concept of a set as formalized in set theory — a collection defined entirely by its membership relation. When Python raises TypeError instead of returning an arbitrary element, it's preserving this mathematical contract. The error is a type-system boundary: it tells you that you're applying a sequence operation to a membership container, and that's a conceptual mismatch, not a missing feature.
Thinking in terms of ADTs clarifies which container to reach for. Ask yourself what the defining operation of your use case is:
- "I need to access the third item" → Sequence (list, tuple)
- "I need to check if this item exists" → Set
- "I need to look up a value by key" → Mapping (dict)
The PEP Trail
Several PEPs shaped how Python's set type works and how it fits into the language's type system:
PEP 218 — Adding a Built-In Set Object Type (2000). Co-authored by Greg Wilson and Raymond Hettinger. First implemented as the sets module in Python 2.3's standard library, then promoted to the built-in set and frozenset types in Python 2.4. The PEP explicitly describes its goal as adding an unordered collection of unique values, modeled on mathematical set theory (source: PEP 218).
PEP 3119 — Introducing Abstract Base Classes (2007). Guido van Rossum and Talin. This PEP formalized the distinction between sequences, sets, and mappings in Python's type system. The resulting collections.abc.Set ABC defines the contract: __contains__, __iter__, __len__ — and notably not __getitem__ (source: PEP 3119).
PEP 20 — The Zen of Python (2004). Tim Peters. Originally written in 1999 and formalized as a PEP in 2004. The principles "In the face of ambiguity, refuse the temptation to guess" and "There should be one — and preferably only one — obvious way to do it" both argue against adding fake indexing to sets. If you need indexing, use a sequence. If you need membership testing, use a set (source: PEP 20).
PEP 584 — Add Union Operators to dict (2019). Steven D'Aprano and Brandt Bucher. This PEP brought the | and |= operators to dict in Python 3.9, aligning dictionary merging syntax with the set operators that have used | and |= since sets were introduced. The parallel reinforces the kinship between sets and dicts as hash-based containers, distinct from the sequence family (source: PEP 584).
The Decision That Matters
When you get TypeError: 'set' object is not subscriptable, Python isn't being unhelpful. It's asking you a question: do you actually need positions, or do you need membership?
If you need to ask "what's at position 3?", use a list or a tuple. These are sequences — they have positions, and accessing by position is their defining capability.
If you need to ask "is this element present?", "what elements are in both collections?", or "give me only the unique values," use a set. These are operations sets were built for, and they execute in constant time precisely because sets don't waste any effort maintaining positional information.
If you need both — uniqueness and indexing — acknowledge that you're asking for two different ADT contracts in one container, and choose accordingly: dict.fromkeys() for quick deduplication with order, sorted() for a deterministic snapshot, or a third-party OrderedSet or SortedSet if you need the full interface.
Sets are unordered. Things without order don't have positions. Things without positions can't be indexed. The TypeError isn't a bug or a missing feature — it's Python refusing to let you treat an unordered container as if it were ordered, because if it did, the resulting code would be unpredictable, fragile, and wrong in ways you might not discover until production.
Choose the right container for the question you're asking, and the indexing problem disappears. That's not a workaround. That's Python working exactly as designed.