šŸš€ LEVEL UP TO SENIOR:Unlock 500+ Advanced Practical Challenges & Exercises.
šŸŽ“ COURSERA PARTNER:Earn professional Google, Meta, and IBM certificates to supercharge your resume.
HTML MASTER CLASS /// LEARN TAGS /// BUILD STRUCTURE /// SEMANTIC WEB /// HTML MASTER CLASS /// LEARN TAGS ///

Python Union Types

Model values that can legitimately be one of several types — using the modern X | Y syntax, discriminated unions, and isinstance narrowing that keeps your type checker and your runtime logic in sync.

⚔ Total XP: 0|šŸ’» python XP: 0

Skill Matrix

UNLOCK NODES BY LEARNING NEW TAGS.

System Hub

Core logic.

Quick Quiz //

On Python 3.10+, what does int | str mean as a type annotation?


šŸš€ LEVEL UP TO SENIOR:Unlock 500+ Advanced Practical Challenges & Exercises.
šŸŽ“ COURSERA PARTNER:Earn professional Google, Meta, and IBM certificates to supercharge your resume.

Real-world data is rarely a single, uniform type — an API might return a string or a number, a parser might produce a Success or a Failure. Union types let you express that honestly in the type system, instead of quietly typing everything as Any and losing all safety.

1From typing.Union to the | Operator

Before Python 3.10, expressing a union required from typing import Union and Union[int, str]. PEP 604 introduced the | operator as syntactic sugar directly on types, so int | str means exactly the same thing with far less ceremony — and unlike Union[...], it also works as a genuine runtime value in some contexts (e.g. isinstance(x, int | str) is valid from 3.10 onward too).

Optional[X] is really just shorthand for Union[X, None], which is why it's now equally idiomatic to write X | None directly — many style guides and linters (including Ruff) now flag Optional[X] in favor of the pipe syntax for consistency in codebases targeting 3.10+.

On Python versions before 3.10, you still need Union[int, str] from the typing module, or from __future__ import annotations combined with string-quoted annotations to use the pipe syntax without a runtime error — a detail that matters a great deal when a library needs to support older interpreters.

āœ•
—
+
def format_id(value: int | str) -> str:
    return f"ID-{value}"

format_id(42)     # OK
format_id("A7")   # also OK
localhost:3000
Console Output
format_id(42)
'ID-42'

2Narrowing: Turning a Union Back Into a Specific Type

A union type is deliberately vague — int | str tells the checker only that the value is one of the two, not which one, so calling a method that only exists on str would be flagged as unsafe before you've established which branch you're in. Narrowing is the process of proving, within a specific block of code, that the value must be one specific member of the union.

isinstance() checks are the most common narrowing tool: inside if isinstance(value, int):, mypy treats value as int for the remainder of that block, and — because of the implicit else — as str in whatever follows, since int | str minus int leaves only str. Equality checks against literal values (if x == "success":), is None checks, and pattern matching (covered in the next lesson) are other constructs the checker recognizes as narrowing.

Writing code this way — checking, then acting only within the guarded branch — is not just a type-checker satisfaction exercise. It's the same discipline that prevents AttributeError and TypeError at runtime; the type checker is simply making that discipline visible and verifiable before the code ever executes.

āœ•
—
+
def double(value: int | str) -> int | str:
    if isinstance(value, int):
        return value * 2       # mypy knows: value is int here
    return value + value       # mypy knows: value is str here
localhost:3000
mypy Reasoning
Inside isinstance(value, int):
value narrowed from int | str to int

3Discriminated Unions for Modeling Outcomes

A particularly powerful pattern is the *discriminated* (or *tagged*) union: a union of types that each carry a shared literal field — often called kind or type — whose value uniquely identifies which variant you're holding. Modeling a Result as Success | Failure, each a small dataclass with its own kind literal, lets you branch on result.kind == "success" and have the type checker narrow result to Success inside that branch, giving you safe access to .data without a manual getattr or a try/except AttributeError.

This pattern directly replaces a common anti-pattern: returning None on failure and a value on success, which forces every caller to remember to check for None with no way for the type checker to guarantee they did. A discriminated union return type makes 'did you handle the failure case?' a question the type checker can actually answer, not just a code-review reminder.

Libraries like pydantic and attrs, and the standard library's own dataclasses, all work naturally with this pattern — combined with match/case structural pattern matching (the next lesson), discriminated unions become one of the most ergonomic ways to model 'one of several distinct shapes of data' in modern Python.

āœ•
—
+
from dataclasses import dataclass

@dataclass
class Success:
    kind: str = "success"
    data: dict = None

@dataclass
class Failure:
    kind: str = "failure"
    error: str = ""

Result = Success | Failure
localhost:3000
Type Model
Result = Success | Failure
Each variant is a distinct, narrowable dataclass

4Step-by-Step Breakdown

Not every value has exactly one type. Python's union syntax lets you say 'this is a string OR an int' — and actually have your tools check it.

Since Python 3.10, you write unions with the pipe operator: int | str means 'an int or a str'. No typing import needed.

Checkpoint: On Python 3.10+, what does int | str mean as a type annotation?

  • →The value is either an int or a str
  • →The value must satisfy both int and str simultaneously

Before narrowing a union, you can't safely call type-specific methods. isinstance() checks let mypy narrow the type within each branch.

Checkpoint: Why does mypy allow value * 2 inside the isinstance(value, int) branch but not before the check?

  • →Type narrowing — mypy tracks that the branch guarantees value is int
  • →Python itself restricts the variable at runtime after the check

Discriminated unions use a shared 'tag' field (often via dataclasses) so you can safely branch on which variant you have.

Once you can express 'one of several types', pattern matching becomes the natural way to handle each case — that's next.

Handle a Real Union Type. Finish process_result(): isinstance() narrows a union type before handling each case.

Level Up šŸš€

Advanced cheat sheets, SEO tricks, and interview prep for this topic.

Browser Support

ChromeSupported

Fully supported (via server-side Python execution).

FirefoxSupported

Fully supported (via server-side Python execution).

SafariSupported

Fully supported (via server-side Python execution).

EdgeSupported

Fully supported (via server-side Python execution).

Best Practices

Prefer X | None over Optional[X] on 3.10+ codebases

They are semantically identical, but the pipe syntax is now the community and tooling standard (Ruff's UP007 rule flags Optional[X] for this reason), keeping the codebase visually consistent.

Model exclusive outcomes as discriminated unions, not sentinel None returns

A Success | Failure return type forces callers to handle both cases explicitly and lets the type checker verify it, unlike a bare Optional return that's easy to silently ignore.

Frequent Bugs

THE BUG

Calling a type-specific method on a union value before narrowing it, then hitting an AttributeError at runtime on the branch the type checker would have caught.

THE FIX

Always isinstance()-check (or otherwise narrow) a union value before calling a method that only exists on one of its members, and let mypy/pyright verify every branch is covered.

Real-World Examples

Parsing an API Response That Can Be a String ID or a Full Object

A third-party API sometimes returns just a numeric ID and sometimes a full nested object for the same field, depending on a query parameter — a common real-world API inconsistency.

def extract_user_id(value: int | dict) -> int:
    if isinstance(value, dict):
        return value["id"]
    return value

Interview Prep

?Frequently Asked Questions

Pascual Vila

Pascual Vila

Frontend Instructor // Code Syllabus

Common Pitfalls & Errors

The Error //

Forgetting to narrow a union before calling a member-specific method, producing code that type-checks incorrectly or crashes at runtime on the untested branch.

# Wrong: .upper() doesn't exist on int def shout(value: int | str) -> str: return value.upper() # Correct: narrow first def shout(value: int | str) -> str: if isinstance(value, str): return value.upper() return str(value)

The Solution //

Add an explicit isinstance() (or discriminant field check) before calling any method that is not common to every member of the union, and let mypy confirm every branch is handled.

Lesson Glossary

[01]Union type

A type annotation indicating a value may be one of several specified types, written as X | Y (3.10+) or Union[X, Y].

Code Preview
// Union type context

[02]Type narrowing

A static checker's process of refining a union type to a more specific type within a guarded code branch, e.g. after an isinstance() check.

Code Preview
// Type narrowing context

[03]Discriminated union

A union of types that each carry a common literal "tag" field identifying which variant is present, enabling safe, checkable branching.

Code Preview
// Discriminated union context

[04]Optional[X]

Shorthand for Union[X, None] (equivalently X | None), indicating a value may be absent.

Code Preview
// Optional[X] context

Continue Learning