Database lookups, configuration values, and user input often produce "nothing found" results. Optional types make the possibility of None explicit in your code, forcing you to handle missing values before type checkers let you use the result.

In Python, None is commonly used to represent "missing" or "not found". Type hints make this explicit.

Optional Basics

name2
optional_basics.py
Replay: real traced execution (multi-file project)
# Optional basics

from typing import Optional

# Two equivalent ways
name1: Optional[str] = "Alice"
name2: str | None = None

print("name1:", name1)
print("name2:", name2)

# Function returning optional

def lookup_city(user_id: int) -> str | None:
    cities: dict[int, str] = {1: "Paris", 2: "Tokyo"}
    return cities.get(user_id)

print("lookup_city(1) =", lookup_city(1))
print("lookup_city(99) =", lookup_city(99))

# Optional basics

from typing import Optional

# Two equivalent ways
name1: Optional[str] = "Alice"
name2: str | None = "Maya"

print("name1:", name1)
print("name2:", name2)

# Function returning optional

def lookup_city(user_id: int) -> str | None:
    cities: dict[int, str] = {1: "Paris", 2: "Tokyo"}
    return cities.get(user_id)

print("lookup_city(1) =", lookup_city(1))
print("lookup_city(99) =", lookup_city(99))

# Optional basics

from typing import Optional

# Two equivalent ways
name1: Optional[str] = "Alice"
name2: str | None = "Jordan"

print("name1:", name1)
print("name2:", name2)

# Function returning optional

def lookup_city(user_id: int) -> str | None:
    cities: dict[int, str] = {1: "Paris", 2: "Tokyo"}
    return cities.get(user_id)

print("lookup_city(1) =", lookup_city(1))
print("lookup_city(99) =", lookup_city(99))

  1. name1 ← Alice, name2 ← None

    5# Two equivalent ways6name1→ Alice: Optional[str] = "Alice"7name2→ None: str | None = NoneNone8#@name2="Maya", "Jordan"910print("name1:", name1Alice)11print("name2:", name2None)1213# Function returning optional1415def lookup_city(user_id: int) -> str | None:16    cities: dict[int, str] = {1: "Paris", 2: "Tokyo"}17    return cities.get(user_id)1819print("lookup_city(1) =", lookup_city(1))20print("lookup_city(99) =", lookup_city(99))
    outputname1: Alice
    name2: None
  2. cities ← {1: 'Paris', 2: 'Tokyo'}

    pass 1 of 2
    15def lookup_city(user_id1: int) -> str | None:16    cities→ {1: 'Paris', 2: 'Tokyo'}: dict[int, str] = {1: "Paris", 2: "Tokyo"}17    return cities{1: 'Paris', 2: 'Tokyo'}.get(user_id1)
  3. print("lookup_city(1) =", lookup_city(1))

    19print("lookup_city(1) =", lookup_city(1))20print("lookup_city(99) =", lookup_city(99))
    outputlookup_city(1) = Paris
  4. cities ← {1: 'Paris', 2: 'Tokyo'}

    pass 2 of 2
    15def lookup_city(user_id99: int) -> str | None:16    cities→ {1: 'Paris', 2: 'Tokyo'}: dict[int, str] = {1: "Paris", 2: "Tokyo"}17    return cities{1: 'Paris', 2: 'Tokyo'}.get(user_id99)
  5. print("lookup_city(99) =", lookup_city(99))

    19print("lookup_city(1) =", lookup_city(1))20print("lookup_city(99) =", lookup_city(99))
    outputlookup_city(99) = None
  1. name1 ← Alice, name2 ← Maya

    5# Two equivalent ways6name1→ Alice: Optional[str] = "Alice"7name2→ Maya: str | None = "Maya"89print("name1:", name1Alice)10print("name2:", name2Maya)1112# Function returning optional1314def lookup_city(user_id: int) -> str | None:15    cities: dict[int, str] = {1: "Paris", 2: "Tokyo"}16    return cities.get(user_id)1718print("lookup_city(1) =", lookup_city(1))19print("lookup_city(99) =", lookup_city(99))
    outputname1: Alice
    name2: Maya
  2. cities ← {1: 'Paris', 2: 'Tokyo'}

    pass 1 of 2
    14def lookup_city(user_id1: int) -> str | None:15    cities→ {1: 'Paris', 2: 'Tokyo'}: dict[int, str] = {1: "Paris", 2: "Tokyo"}16    return cities{1: 'Paris', 2: 'Tokyo'}.get(user_id1)
  3. print("lookup_city(1) =", lookup_city(1))

    18print("lookup_city(1) =", lookup_city(1))19print("lookup_city(99) =", lookup_city(99))
    outputlookup_city(1) = Paris
  4. cities ← {1: 'Paris', 2: 'Tokyo'}

    pass 2 of 2
    14def lookup_city(user_id99: int) -> str | None:15    cities→ {1: 'Paris', 2: 'Tokyo'}: dict[int, str] = {1: "Paris", 2: "Tokyo"}16    return cities{1: 'Paris', 2: 'Tokyo'}.get(user_id99)
  5. print("lookup_city(99) =", lookup_city(99))

    18print("lookup_city(1) =", lookup_city(1))19print("lookup_city(99) =", lookup_city(99))
    outputlookup_city(99) = None
  1. name1 ← Alice, name2 ← Jordan

    5# Two equivalent ways6name1→ Alice: Optional[str] = "Alice"7name2→ Jordan: str | None = "Jordan"89print("name1:", name1Alice)10print("name2:", name2Jordan)1112# Function returning optional1314def lookup_city(user_id: int) -> str | None:15    cities: dict[int, str] = {1: "Paris", 2: "Tokyo"}16    return cities.get(user_id)1718print("lookup_city(1) =", lookup_city(1))19print("lookup_city(99) =", lookup_city(99))
    outputname1: Alice
    name2: Jordan
  2. cities ← {1: 'Paris', 2: 'Tokyo'}

    pass 1 of 2
    14def lookup_city(user_id1: int) -> str | None:15    cities→ {1: 'Paris', 2: 'Tokyo'}: dict[int, str] = {1: "Paris", 2: "Tokyo"}16    return cities{1: 'Paris', 2: 'Tokyo'}.get(user_id1)
  3. print("lookup_city(1) =", lookup_city(1))

    18print("lookup_city(1) =", lookup_city(1))19print("lookup_city(99) =", lookup_city(99))
    outputlookup_city(1) = Paris
  4. cities ← {1: 'Paris', 2: 'Tokyo'}

    pass 2 of 2
    14def lookup_city(user_id99: int) -> str | None:15    cities→ {1: 'Paris', 2: 'Tokyo'}: dict[int, str] = {1: "Paris", 2: "Tokyo"}16    return cities{1: 'Paris', 2: 'Tokyo'}.get(user_id99)
  5. print("lookup_city(99) =", lookup_city(99))

    18print("lookup_city(1) =", lookup_city(1))19print("lookup_city(99) =", lookup_city(99))
    outputlookup_city(99) = None
Optional[T] - a type alias meaning `T | None`, indicating the value might be missing

Type Narrowing

Type checkers can narrow an optional value after checks:

narrowing.py
Replay: real traced execution (multi-file project)
# Narrowing optionals

def greet(name: str | None) -> str:
    # Narrowing with is None
    if name is None:
        return "Hello, stranger"

    # Here name is treated as str by type checkers
    return f"Hello, {name.upper()}"

print(greet("Alice"))
print(greet(None))

# Guard function

def ensure_str(value: str | None) -> str:
    if value is None:
        raise ValueError("value is required")
    return value

print("ensure_str('x') =", ensure_str("x"))

  1. print(greet("Alice"))

    11print(greet("Alice"))12print(greet(None))
  2. def greet(name: str | None) -> str: # Narrowing with is None

    pass 1 of 2
    3def greet(nameAlice: str | None) -> str:4    # Narrowing with is None5    if name is None:6        return "Hello, stranger"78    # Here name is treated as str by type checkers9    return f"Hello, {nameAlice.upper()}"
  3. print(greet("Alice"))

    11print(greet("Alice"))12print(greet(None))
    outputHello, ALICE
  4. def greet(name: str | None) -> str: # Narrowing with is None

    pass 2 of 2
    3def greet(nameNone: str | None) -> str:4    # Narrowing with is None5    if name is None:6        return "Hello, stranger"
  5. if name is None:

    4# Narrowing with is None5if nameNone is None:6    return "Hello, stranger"
  6. print(greet(None))

    11print(greet("Alice"))12print(greet(None))1314# Guard function1516def ensure_str(value: str | None) -> str:17    if value is None:18        raise ValueError("value is required")19    return value2021print("ensure_str('x') =", ensure_str("x"))
    outputHello, stranger
  7. def ensure_str(value: str | None) -> str:

    16def ensure_str(valuex: str | None) -> str:17    if value is None:18        raise ValueError("value is required")19    return valuex
  8. print("ensure_str('x') =", ensure_str("x"))

    21print("ensure_str('x') =", ensure_str("x"))
    outputensure_str('x') = x
narrowing - using `if x is not None:` checks to prove to the type checker that a value exists

Optional in Collections

optional_collections.py
Replay: real traced execution (multi-file project)
# Optional with collections

from typing import Optional

# dict.get returns Optional[V]
ages: dict[str, int] = {"Alice": 30, "Bob": 27}

maybe_age: Optional[int] = ages.get("Charlie")
print("maybe_age:", maybe_age)

# Provide a default to avoid Optional
age_or_zero: int = ages.get("Charlie", 0)
print("age_or_zero:", age_or_zero)

# Optional element in list
scores: list[int | None] = [10, None, 30]

# Filter out None safely
clean: list[int] = [s for s in scores if s is not None]
print("clean:", clean)

  1. ages ← {'Alice': 30, 'Bob': 27}, maybe_age ← None, age_or_zero ← 0

    5# dict.get returns Optional[V]6ages→ {'Alice': 30, 'Bob': 27}: dict[str, int] = {"Alice": 30, "Bob": 27}78maybe_age→ None: Optional[int] = ages{'Alice': 30, 'Bob': 27}.get("Charlie")9print("maybe_age:", maybe_ageNone)1011# Provide a default to avoid Optional12age_or_zero→ 0: int = ages{'Alice': 30, 'Bob': 27}.get("Charlie", 0)13print("age_or_zero:", age_or_zero0)1415# Optional element in list16scores→ [10, None, 30]: list[int | None] = [10, None, 30]1718# Filter out None safely19clean→ [10, 30]: list[int] = [s for s in scores[10, None, 30] if s is not None]20print("clean:", clean[10, 30])
    outputmaybe_age: None
    age_or_zero: 0
    clean: [10, 30]

Sentinel Values

Sometimes None is a valid value. In that case, use a sentinel object to represent "not provided".

sentinel.py
Replay: real traced execution (multi-file project)
# Sentinel values

from typing import Any

# Sentinel object
MISSING = object()

# None could be a valid value, so we need a different “not provided” marker.

def get_setting(settings: dict[str, Any], key: str, default: Any = MISSING) -> Any:
    value = settings.get(key, MISSING)
    if value is not MISSING:
        return value

    if default is MISSING:
        raise KeyError(key)

    return default

cfg: dict[str, Any] = {"timeout": None, "retries": 3}

print("timeout (explicit None) =", get_setting(cfg, "timeout"))
print("retries =", get_setting(cfg, "retries"))
print("missing with default =", get_setting(cfg, "missing", 0))

  1. MISSING ← ⟨object A⟩, cfg ← {'timeout': None, 'retries': 3}

    5# Sentinel object6MISSING→ ⟨object A⟩ = object()78# None could be a valid value, so we need a different “not provided” marker.910def get_setting(settings: dict[str, Any], key: str, default: Any = MISSING) -> Any:11    value = settings.get(key, MISSING)12    if value is not MISSING:13        return value1415    if default is MISSING:16        raise KeyError(key)1718    return default1920cfg→ {'timeout': None, 'retries': 3}: dict[str, Any] = {"timeout": None, "retries": 3}2122print("timeout (explicit None) =", get_setting(cfg{'timeout': None, 'retries': 3}, "timeout"))23print("retries =", get_setting(cfg, "retries"))
  2. value ← None

    pass 1 of 3
    10def get_setting(settings{'timeout': None, 'retries': 3}: dict[str, Any], keytimeout: str, default⟨object A⟩: Any = MISSING⟨object A⟩) -> Any:11    value→ None = settings{'timeout': None, 'retries': 3}.get(keytimeout, MISSING⟨object A⟩)12    if value is not MISSING:
    All 3 passes — pass 1 is the card above
    passkeydefaultvalue
    1timeout⟨object A⟩None
    2retries⟨object A⟩3
    3missing0⟨object A⟩
  3. if value is not MISSING:

    pass 1 of 2
    11value = settings.get(key, MISSING)12if valueNone is not MISSING⟨object A⟩:13    return valueNone
  4. print("timeout (explicit None) =", get_setting(cfg, "timeout"))

    22print("timeout (explicit None) =", get_setting(cfg{'timeout': None, 'retries': 3}, "timeout"))23print("retries =", get_setting(cfg{'timeout': None, 'retries': 3}, "retries"))24print("missing with default =", get_setting(cfg, "missing", 0))
    outputtimeout (explicit None) = None
  5. if value is not MISSING:

    pass 2 of 2
    11value = settings.get(key, MISSING)12if value3 is not MISSING⟨object A⟩:13    return value3
  6. print("retries =", get_setting(cfg, "retries"))

    22print("timeout (explicit None) =", get_setting(cfg, "timeout"))23print("retries =", get_setting(cfg{'timeout': None, 'retries': 3}, "retries"))24print("missing with default =", get_setting(cfg{'timeout': None, 'retries': 3}, "missing", 0))
    outputretries = 3
  7. print("missing with default =", get_setting(cfg, "missing", 0))

    23print("retries =", get_setting(cfg, "retries"))24print("missing with default =", get_setting(cfg{'timeout': None, 'retries': 3}, "missing", 0))
    outputmissing with default = 0
sentinel - a unique object used when `None` is a valid value and you need to distinguish "not provided"

Parsing Optional Values

parse_optional.py
Replay: real traced execution (multi-file project)
# Parsing that can fail

from typing import Optional

# Optional parse

def try_parse_int(text: str) -> Optional[int]:
    try:
        return int(text)
    except ValueError:
        return None

inputs = ["10", "x", "42"]
parsed: list[int] = []

for t in inputs:
    value = try_parse_int(t)
    if value is None:
        print("skip:", t)
        continue
    parsed.append(value)

print("parsed:", parsed)

# Alternative: raise instead of Optional

def parse_int(text: str) -> int:
    return int(text)

print("parse_int('7') =", parse_int("7"))

  1. inputs ← ['10', 'x', '42'], parsed ← []

    13inputs→ ['10', 'x', '42'] = ["10", "x", "42"]14parsed→ []: list[int] = []
  2. for t in inputs:

    pass 1 of 3
    16for t10 in inputs['10', 'x', '42']:17    value = try_parse_int(t10)18    if value is None:
    All 3 passes — pass 1 is the card above
    passt
    110
    2x
    342
  3. def try_parse_int(text: str) -> Optional[int]:

    pass 1 of 3
    7def try_parse_int(text10: str) -> Optional[int]:8    try:9        return int(text)
    All 3 passes — pass 1 is the card above
    passtext
    110
    2x
    342
  4. try:

    pass 1 of 3
    7def try_parse_int(text: str) -> Optional[int]:8    try:9        return int(text10)10    except ValueError:
    All 3 passes — pass 1 is the card above
    passtext
    110
    2x
    342
  5. value ← 10, parsed ← [10]

    16for t in inputs:17    value→ 10 = try_parse_int(t10)18    if value is None:19        print("skip:", t)20        continue21    parsed→ [10].append(value10)
  6. value ← None

    16for t in inputs:17    value→ None = try_parse_int(tx)18    if value is None:
  7. if value is None:

    17value = try_parse_int(t)18if valueNone is None:19    print("skip:", tx)20    continue21parsed.append(value)
    outputskip: x
  8. value ← 42, parsed ← [10, 42]

    16for t in inputs:17    value→ 42 = try_parse_int(t42)18    if value is None:19        print("skip:", t)20        continue21    parsed→ [10, 42].append(value42)
  9. print("parsed:", parsed)

    23print("parsed:", parsed[10, 42])2425# Alternative: raise instead of Optional2627def parse_int(text: str) -> int:28    return int(text)2930print("parse_int('7') =", parse_int("7"))
    outputparsed: [10, 42]
  10. def parse_int(text: str) -> int:

    27def parse_int(text7: str) -> int:28    return int(text7)
  11. print("parse_int('7') =", parse_int("7"))

    30print("parse_int('7') =", parse_int("7"))
    outputparse_int('7') = 7

Exercise: practical.py

Build a config loader that handles missing keys gracefully