Python Literal Types: Restricting Values

• Literal types restrict function parameters to specific values, catching invalid arguments at type-check time rather than runtime

Key Insights

• Literal types restrict function parameters to specific values, catching invalid arguments at type-check time rather than runtime • Use Literal types for small, fixed sets of values (HTTP methods, modes, states); switch to Enums when you need more than 10-15 values or additional behavior • Type checkers leverage Literal types for exhaustiveness checking and control flow narrowing, making your code provably correct

Introduction to Literal Types

Python’s type system has evolved considerably since PEP 484 introduced type hints. While basic type annotations like str or int tell you what kind of data to expect, they don’t help when a function should only accept specific values. This is where Literal types shine.

Consider a function that configures logging levels. Without Literal types, you might write:

def set_log_level(level: str) -> None:
    if level == "DEBUG":
        # configure debug logging
        pass
    elif level == "INFO":
        # configure info logging
        pass
    elif level == "ERROR":
        # configure error logging
        pass
    else:
        raise ValueError(f"Invalid log level: {level}")

The type checker accepts any string, even set_log_level("DEBUGG") or set_log_level("banana"). These bugs only surface at runtime. Literal types solve this by restricting the acceptable values at the type level.

Basic Literal Type Syntax

Literal types were introduced in Python 3.8 via PEP 586. You import them from the typing module:

from typing import Literal

def set_log_level(level: Literal["DEBUG", "INFO", "ERROR"]) -> None:
    if level == "DEBUG":
        # configure debug logging
        pass
    elif level == "INFO":
        # configure info logging
        pass
    else:  # level must be "ERROR"
        # configure error logging
        pass

Now set_log_level("DEBUGG") produces a type error before you even run the code. Type checkers like mypy, pyright, and pyre will flag this immediately.

Literals work with strings, integers, booleans, and None:

def make_http_request(
    url: str,
    method: Literal["GET", "POST", "PUT", "DELETE"]
) -> dict:
    # implementation
    pass

def set_status_code(code: Literal[200, 201, 400, 404, 500]) -> None:
    # implementation
    pass

def toggle_feature(enabled: Literal[True]) -> None:
    # Only accepts True, not False
    pass

The boolean example might seem odd, but it’s useful for function overloads or when you want to enforce a specific boolean value.

Combining and Composing Literal Types

For better maintainability, extract Literal types into type aliases:

from typing import Literal

HttpMethod = Literal["GET", "POST", "PUT", "DELETE", "PATCH"]
Environment = Literal["development", "staging", "production"]
ContentType = Literal["application/json", "application/xml", "text/plain"]

def api_request(
    method: HttpMethod,
    endpoint: str,
    content_type: ContentType = "application/json"
) -> dict:
    # implementation
    pass

You can combine Literal types using union syntax. In Python 3.10+, use the | operator:

# Python 3.10+
Color = Literal["red", "green", "blue"] | Literal["cyan", "magenta", "yellow"]

# Equivalent to:
Color = Literal["red", "green", "blue", "cyan", "magenta", "yellow"]

For earlier Python versions, use Union:

from typing import Union, Literal

Color = Union[Literal["red", "green", "blue"], Literal["cyan", "magenta", "yellow"]]

Combining different types of literals is also valid:

ConfigValue = Literal["auto"] | Literal[0, 1, 2] | Literal[True, False]

def set_config(value: ConfigValue) -> None:
    pass

set_config("auto")  # OK
set_config(1)       # OK
set_config(True)    # OK
set_config("manual")  # Type error

Literal Types with Enums

Enums and Literal types solve similar problems but with different trade-offs. Enums provide namespace protection, methods, and better IDE support. Literal types are lightweight and work seamlessly with existing string/int-based APIs.

Here’s the same API using both approaches:

from enum import Enum
from typing import Literal

# Using Enum
class HttpMethodEnum(Enum):
    GET = "GET"
    POST = "POST"
    PUT = "PUT"
    DELETE = "DELETE"

def request_with_enum(method: HttpMethodEnum) -> None:
    print(f"Making {method.value} request")

request_with_enum(HttpMethodEnum.GET)  # Must use enum

# Using Literal
HttpMethod = Literal["GET", "POST", "PUT", "DELETE"]

def request_with_literal(method: HttpMethod) -> None:
    print(f"Making {method} request")

request_with_literal("GET")  # Direct string works

Use Enums when:

  • You have more than 10-15 values
  • You need methods or additional behavior
  • You want namespace protection
  • Values might change independently from names

Use Literals when:

  • You have a small, fixed set of values
  • You’re working with existing string/int-based APIs
  • You want minimal boilerplate
  • The values themselves are meaningful (not just labels)

You can even combine them:

from enum import Enum
from typing import Literal, get_args

class Status(Enum):
    PENDING = "pending"
    ACTIVE = "active"
    COMPLETED = "completed"

# Create a Literal from Enum values
StatusLiteral = Literal["pending", "active", "completed"]

def process_status(status: StatusLiteral) -> None:
    # Works with plain strings
    pass

Type Narrowing and Type Guards

Literal types enable powerful type narrowing in control flow. Type checkers understand that after certain checks, the type becomes more specific:

from typing import Literal

Mode = Literal["read", "write", "append"]

def open_file(mode: Mode) -> None:
    if mode == "read":
        # Type checker knows mode is Literal["read"] here
        print("Opening in read-only mode")
    elif mode == "write":
        # Type checker knows mode is Literal["write"] here
        print("Opening in write mode")
    else:
        # Type checker knows mode is Literal["append"] here
        print("Opening in append mode")

Python 3.10’s match statement works exceptionally well with Literal types, providing exhaustiveness checking:

from typing import Literal

State = Literal["idle", "running", "paused", "stopped"]

def handle_state(state: State) -> str:
    match state:
        case "idle":
            return "Ready to start"
        case "running":
            return "Currently executing"
        case "paused":
            return "Temporarily suspended"
        case "stopped":
            return "Execution complete"
        # If you forget a case, type checkers will warn you

With mypy’s --warn-unreachable flag, you can verify exhaustiveness:

def handle_state_incomplete(state: State) -> str:
    match state:
        case "idle":
            return "Ready"
        case "running":
            return "Executing"
        # Missing "paused" and "stopped" - mypy will warn

Real-World Use Cases

Literal types excel in configuration and API design. Here’s an environment configuration example:

from typing import Literal
import os

Environment = Literal["development", "staging", "production"]

class Config:
    def __init__(self, env: Environment):
        self.env = env
        self.debug = env == "development"
        self.database_url = self._get_database_url(env)
    
    def _get_database_url(self, env: Environment) -> str:
        match env:
            case "development":
                return "sqlite:///dev.db"
            case "staging":
                return "postgresql://staging-db:5432/app"
            case "production":
                return "postgresql://prod-db:5432/app"

config = Config("development")  # OK
config = Config("test")  # Type error

State machines benefit from Literal types:

from typing import Literal

State = Literal["draft", "submitted", "approved", "rejected"]
Event = Literal["submit", "approve", "reject", "revise"]

def transition(current: State, event: Event) -> State:
    if current == "draft" and event == "submit":
        return "submitted"
    elif current == "submitted" and event == "approve":
        return "approved"
    elif current == "submitted" and event == "reject":
        return "rejected"
    elif current in ("approved", "rejected") and event == "revise":
        return "draft"
    else:
        raise ValueError(f"Invalid transition: {current} + {event}")

Web frameworks like FastAPI leverage Literal types for route definitions:

from typing import Literal
from fastapi import FastAPI

app = FastAPI()

HttpMethod = Literal["GET", "POST", "PUT", "DELETE"]

@app.api_route("/items/{item_id}", methods=["GET", "POST"])
def handle_item(item_id: int, method: HttpMethod):
    if method == "GET":
        return {"action": "fetch", "item_id": item_id}
    else:
        return {"action": "create", "item_id": item_id}

Limitations and Best Practices

Literal types have compile-time impact only. At runtime, they’re just regular values:

from typing import Literal

def process(value: Literal["a", "b", "c"]) -> None:
    pass

# At runtime, this is just:
def process(value) -> None:
    pass

Don’t use Literal types for large value sets. This is an anti-pattern:

# BAD: Too many values
CountryCode = Literal[
    "US", "UK", "CA", "AU", "DE", "FR", "IT", "ES", "JP", "CN",
    # ... 200+ more countries
]

Instead, use validation with a set or Enum:

# GOOD: Runtime validation
VALID_COUNTRIES = {"US", "UK", "CA", "AU", "DE", "FR", "IT", "ES", "JP", "CN"}

def process_country(code: str) -> None:
    if code not in VALID_COUNTRIES:
        raise ValueError(f"Invalid country code: {code}")
    # process

When migrating existing code, start with the most critical functions—those where invalid values cause bugs or security issues. Type aliases make migration easier:

# Before
def set_mode(mode: str) -> None:
    pass

# After: Create alias first
Mode = Literal["fast", "safe", "balanced"]

def set_mode(mode: Mode) -> None:
    pass

Literal types bring compile-time guarantees to value-restricted parameters. They’re lightweight, composable, and integrate seamlessly with Python’s type system. Use them for small, fixed value sets where runtime errors are costly, and watch your type checker catch bugs before they reach production.

Liked this? There's more.

Every week: one practical technique, explained simply, with code you can use immediately.