The code indexes a value with [...] and Python stops with this (Python 3.14.7, path shortened; 3.12 and 3.13 print the same text):
Traceback (most recent call last):
File "none1.py", line 8, in <module>
print(user["email"])
~~~~^^^^^^^^^
TypeError: 'NoneType' object is not subscriptableuser holds None, and None cannot be indexed. Its twin, AttributeError: 'NoneType' object has no attribute 'group', is the same bug with a dot instead of square brackets. The line in the traceback is where the None was used; the real fix is almost always where it was produced.
Quick fix checklist
- Look at the
~~~^^^markers under the failing line (Python 3.11+) to see exactly which expression wasNone. - Find where that variable was assigned and ask “can this return
None?” - Make sure the function returns a value on every path; a function that reaches its end without
returnreturnsNone. - Never assign the result of an in-place method:
items = items.sort()setsitemstoNone. Useitems.sort()alone, orsorted(items). - Check
re.match(...)/re.search(...)results before calling.group(). dict.get(key)returnsNonefor a missing key: pass a default, or used[key]if the key is required.- Annotate with
-> X | Noneand run mypy or pyright to catch the remaining cases.
Before you start
You should be comfortable with functions, return, lists and dictionaries. The examples were run with Python 3.14.7, mypy 2.4.0 and pyright 1.1.414. On Python 3.10 and older the messages are identical but the ~~~^^^ position markers are missing, so you need to work out which subscription failed yourself.
Why it happens
obj[key] calls type(obj).__getitem__. Lists, dicts, strings and tuples define it; NoneType does not, so Python raises TypeError. Each operation you attempt on None produces its own wording, which is useful for recognising the family:
'NoneType' object is not subscriptable # x[0]
'NoneType' object has no attribute 'append' # x.append(1)
'NoneType' object does not support item assignment # x[0] = 1
'NoneType' object is not iterable # for i in x
object of type 'NoneType' has no len() # len(x)None reaches your variable in four common ways:
- Implicit return. A function whose last statement is not
return value, or which has a branch that skips thereturn, gives backNone.find_userbelow only returns inside the loop, so a missing user yieldsNone. - In-place methods.
list.sort(),list.append(),list.reverse(),dict.update()andrandom.shuffle()mutate the object and returnNoneon purpose, so you cannot mistake them for functions that return a new object. - “Maybe” APIs.
re.match,re.search,dict.get,os.environ.get,shutil.which,next(it, None)and databasefetchone()all returnNonewhen there is nothing to return. - Defaults. A parameter or attribute initialised to
Nonethat nobody filled in.
def find_user(users, name):
for u in users:
if u["name"] == name:
return u
# falling off the end returns None
users = [{"name": "ana", "email": "ana@example.com"}]
user = find_user(users, "bob")
print(user["email"]) # TypeError: 'NoneType' object is not subscriptableStep-by-step walkthrough
Step 1: Find which expression was None
With chained lookups, the markers matter. Here config has no "cache" key:
config = {"db": {"host": "localhost"}}
port = config.get("cache")["port"] port = config.get("cache")["port"]
~~~~~~~~~~~~~~~~~~~^^^^^^^^
TypeError: 'NoneType' object is not subscriptableThe ~~~ part is the object being indexed (config.get("cache")) and the ^^^ part is the subscript (["port"]). So config.get("cache") was None, not config.
Step 2: Trace it back to where it was assigned
Put breakpoint() on the line before the failure and inspect the value, or add a temporary print(repr(value)). Then read the code that produced it. For a function call, check every path through the function: loops that may not match, if branches without an else, early return statements with no value, and except blocks that log and carry on.
Step 3: Fix it at the source, by category
# 1. Missing return path: decide what "not found" means and make it explicit
def find_user(users, name):
for u in users:
if u["name"] == name:
return u
raise LookupError(f"no user named {name!r}")
# 2. In-place method: don't assign the result
scores = [3, 1, 2]
scores.sort() # or: ranked = sorted(scores)
print(scores[0]) # 1
# 3. Regex: test the match before using it
import re
if (m := re.match(r"(\d+)-(\d+)", "10-20")) is not None:
print(m.group(1)) # 10
# 4. dict.get: supply a default, or index directly if the key is required
port = config.get("cache", {}).get("port", 6379)Which fix is right depends on meaning. If a missing user is a bug, raising an exception at the source gives a clearer error than a TypeError three calls later. If “no result” is normal, return None deliberately and make every caller handle it.
Step 4: Make the type checker find the rest
Annotate functions that can return nothing as X | None (or Optional[X]) and run a checker. Here users.py is the find_user example with -> dict[str, str] | None added, and tags.py calls .group() on an unchecked re.match result. Both checkers reject the unchecked use:
$ mypy users.py
users.py:9: error: Value of type "dict[str, str] | None" is not indexable [index]
$ pyright users.py tags.py
tags.py:3:9 - error: "group" is not a known attribute of "None" (reportOptionalMemberAccess)
users.py:9:7 - error: Object of type "None" is not subscriptable (reportOptionalSubscript)mypy also catches the in-place trap (scores = scores.sort()), because it knows list.sort() returns None:
scores.py:2: error: Incompatible types in assignment (expression has type "None", variable has type "list[int]") [assignment]Worked scenario
A release script compares version tags. It works for 2.1.0 and crashes for tags with a v prefix:
import re
VERSION_RE = re.compile(r"(\d+)\.(\d+)\.(\d+)")
def parse_version(tag: str):
match = VERSION_RE.match(tag)
if match:
return tuple(int(part) for part in match.groups())
def is_newer(tag: str, current: tuple[int, int, int]) -> bool:
return parse_version(tag)[0] > current[0]
print(is_newer("2.1.0", (1, 9, 3))) # True
print(is_newer("v2.1.0", (1, 9, 3))) File "release.py", line 13, in is_newer
return parse_version(tag)[0] > current[0]
~~~~~~~~~~~~~~~~~~^^^
TypeError: 'NoneType' object is not subscriptableDiagnosis. The markers point at parse_version(tag). re.match anchors at the start of the string, so "v2.1.0" does not match, the if is skipped, and the function falls off the end. Running mypy on this file reports Success: no issues found, because parse_version has no return annotation, so mypy does not check its callers’ use of the result. A second bug hides here too: comparing only the major number ([0]) says 1.10.0 is not newer than 1.9.3.
Fix. Accept the prefix, return None explicitly with an honest annotation, and handle the None in the caller:
import re
VERSION_RE = re.compile(r"v?(\d+)\.(\d+)\.(\d+)")
def parse_version(tag: str) -> tuple[int, int, int] | None:
if match := VERSION_RE.fullmatch(tag):
major, minor, patch = (int(part) for part in match.groups())
return major, minor, patch
return None
def is_newer(tag: str, current: tuple[int, int, int]) -> bool:
version = parse_version(tag)
if version is None:
raise ValueError(f"not a version tag: {tag!r}")
return version > currentComparing whole tuples handles minor and patch numbers too. With the annotation in place, deleting the if version is None check makes mypy report Value of type "tuple[int, int, int] | None" is not indexable, so the bug cannot quietly return.
Common mistake
The tempting fix is to silence the symptom:
try:
port = config.get("cache")["port"]
except TypeError:
port = 6379This hides the real question (should cache exist?) and also swallows unrelated TypeErrors from the same line. Two other shortcuts cause new bugs:
if not result:instead ofif result is None:treats0,"",[]and{}as missing. An empty list of users is not the same as “the lookup failed”.value = value or {}has the same problem and can replace a legitimately empty object the caller passed in. Useif value is None: value = {}, which is also how you avoid the shared-default trap in mutable default arguments.
Verify the behavior
Write a check for the input that used to fail, and for the “not found” path, so both branches are exercised:
from versions import parse_version, is_newer
assert parse_version("v2.1.0") == (2, 1, 0)
assert parse_version("latest") is None
assert is_newer("1.10.0", (1, 9, 3))
try:
is_newer("latest", (1, 9, 3))
except ValueError:
pass
else:
raise AssertionError("expected ValueError")
print("all checks passed")$ python3 test_versions.py
all checks passed
$ mypy --strict versions.py
Success: no issues found in 1 source fileAdding mypy or pyright to CI keeps it that way.
Interview exercise
“What does result = [3, 1, 2].sort() leave in result, why was the API designed that way, and how would you design a function that might not find what it is looking for?”
Answer and reasoning
result is None. list.sort() sorts the list in place and returns None deliberately: the Python docs explain that in-place methods return None so you cannot confuse them with a call that produces a new object. sorted() is the version that returns a new list. Mutate-or-return is a form of command-query separation.
For a lookup that can fail there are three reasonable designs. Return X | None when “not found” is normal and callers are expected to branch on it; annotate it so type checkers force the check. Raise an exception (KeyError, LookupError or a domain error) when absence means something is wrong, so the failure surfaces at the source with a useful message rather than as a 'NoneType' object is not subscriptable somewhere downstream. Or accept a default argument, as dict.get(key, default) does, when there is a sensible fallback. The wrong design is the accidental one: a function that returns None only because one branch forgot its return.
Continue learning
Drill these patterns with the Python interview questions and the Python MCQs. Related notes: mutable default arguments, exception chaining for raising clear errors at the source, and KeyError: ‘x’ in Python for the case where the dictionary exists but the key does not. Official references: the Python docs on list.sort(), re.match, the TypeError exception and mypy’s optional types and the None type.