Many real-world values can be absent — a database row that doesn’t exist, a config field that wasn’t set, a function that finds nothing to return. Python uses None to mean “no value”.
Modern Python expresses this with X | None — read as “X or None”.
The X | None pattern
def find_user(user_id: int) -> dict[str, str] | None:
if user_id == 1:
return {"name": "Manikandan"}
return None
result = find_user(99)
print(result) # None
The return type says: this function returns either a user dict or None.
You may see older code with Optional[X]:
from typing import Optional
def find_user(user_id: int) -> Optional[dict[str, str]]:
...
Optional[X] is exactly the same as X | None. The | form is the modern standard — use it.
Type narrowing
When you check for None, pyright narrows the type of the variable inside the branch:
result = find_user(1)
# here, result could be a dict OR None
print(result["name"]) # ERROR — could be None
if result is not None:
# here, pyright knows result is a dict (None is ruled out)
print(result["name"]) # OK
This is the standard pattern: guard with if x is not None:, then use x freely inside the block.
You can also flip it:
if result is None:
return # or raise, or default
print(result["name"]) # OK — past this line, result can't be None
This early return style is preferred when the “missing” case doesn’t need to do much.
Walrus + Optional — a common combo
We mentioned the walrus := in Section 3. It shines when checking for None:
if (user := find_user(1)) is not None:
print(user["name"])
In one line: call the function, name the result, check it, use it inside the block.
Type-narrowing with assertions
Sometimes you know a value isn’t None but pyright can’t prove it. Use assert:
result = find_user(1)
assert result is not None
print(result["name"]) # pyright now treats result as dict[str, str]
The assert acts as a runtime check and a type-narrowing hint. Use it sparingly — usually a proper check is clearer.
Default with or
You can use Python’s or operator to default None to something else:
name: str | None = get_name()
display: str = name or "Anonymous"
But beware: or treats any falsy value as missing — including "" and 0. If you only want to substitute when the value is None, use a conditional:
display: str = name if name is not None else "Anonymous"
Pyright generally prefers the conditional form because the types are more precise.
get() with defaults — dictionaries
The most common source of None in beginner Python is dictionary lookups:
config: dict[str, str] = {"host": "localhost"}
# may raise KeyError
host = config["host"]
# returns None if missing
host: str | None = config.get("host")
# returns the default if missing
host: str = config.get("host", "0.0.0.0")
config.get(key, default) is the cleanest way to handle “maybe-present” lookups.
Multiple-Optional juggling
In real code you’ll often combine several Optional values:
def find_email(user_id: int) -> str | None: ...
def normalize(email: str) -> str: ...
def main() -> None:
email = find_email(42)
if email is not None:
clean = normalize(email)
print(clean)
If you find yourself writing many nested if x is not None: blocks, consider raising an exception at the top instead — single failure point, simpler downstream code:
def main() -> None:
email = find_email(42)
if email is None:
raise ValueError("Email not found")
print(normalize(email))
Avoid Optional for collections — usually
A common smell: list[T] | None. Most of the time you can use an empty list instead of None:
# OK but awkward
def tags(post: Post) -> list[str] | None:
if post.has_tags:
return [...]
return None
# better
def tags(post: Post) -> list[str]:
if post.has_tags:
return [...]
return []
An empty list is its own “no tags” signal — no need to deal with None everywhere downstream.
The exception is when the absence has a different meaning from the empty value — e.g. “user not found” vs “user has zero followers”.
What’s next
Optional handles “value or no value”. Next — Protocols, structural typing that lets you describe shapes without inheritance.