env.dev

Constants in Python: typing.Final, Enum & SCREAMING_SNAKE

Python has no const keyword — constants are SCREAMING_SNAKE_CASE by PEP 8 convention. typing.Final (Python 3.8) adds static-checker enforcement; the enum module handles closed sets.

Last updated:

Python has no const keyword. Constants are a convention — names in SCREAMING_SNAKE_CASE per PEP 8 (2001) — and the language trusts you not to reassign them. PEP 591 (Python 3.8, October 2019) added typing.Final, which makes static type checkers like mypy and Pyright flag rebinding at analysis time, with no runtime cost. For closed sets of values, the enum module (PEP 435, Python 3.4) and its StrEnum sibling (PEP 663, Python 3.11) replace the old "module of uppercase variables" pattern. This guide covers all three layers, the immutable collection types you should prefer, and the standard-library constants worth knowing by heart.

Why does Python use a naming convention instead of a keyword?

Python's design philosophy ("we are all consenting adults") favours convention over enforcement. PEP 8 codified SCREAMING_SNAKE_CASE for module-level constants, and modern linters like Ruff (rule PLR2004, "magic value used in comparison") and Pylint flag accidental reassignment of uppercase names. The convention is a strong social contract; the language itself does not stop you from doing MAX_RETRIES = 999 at runtime.

python
# Module-level constants — PEP 8 convention
MAX_RETRIES = 3
DEFAULT_TIMEOUT = 30.0
API_BASE_URL = "https://api.example.com/v1"
SUPPORTED_FORMATS = ("png", "jpg", "webp")  # tuple, not list

# Nothing prevents this — but linters will complain
MAX_RETRIES = 999

What does typing.Final actually do?

typing.Final is a static-type-checker hint. mypy, Pyright, and pytype see : Final and refuse to type-check any code that reassigns the name. There is no runtime check — at execution time, Final is just an annotation. Pair it with the naming convention to get the strongest signal: visual + tooling.

python
from typing import Final

MAX_CONNECTIONS: Final = 100
API_VERSION: Final[str] = "v2"
FEATURE_FLAGS: Final[dict[str, bool]] = {"new_ui": True, "beta_api": False}

# At runtime, this works (no enforcement):
MAX_CONNECTIONS = 200

# But mypy fails the build:
# error: Cannot assign to final name "MAX_CONNECTIONS"

On a class, Final also blocks subclasses from overriding the attribute — useful for configuration values in framework base classes.

When should you use enum instead of a constant?

Use Enum when the values form a closed set with semantics — HTTP methods, days of the week, connection states. Enum members are singletons, comparable by identity, iterable, and play nicely with Python 3.10's structural pattern matching for exhaustiveness.

python
from enum import Enum, IntEnum, StrEnum, auto

class Color(Enum):
    RED = "red"
    GREEN = "green"
    BLUE = "blue"

class HttpStatus(IntEnum):
    OK = 200
    NOT_FOUND = 404
    SERVER_ERROR = 500

# Python 3.11+ — values are real strings, comparable with str
class LogLevel(StrEnum):
    DEBUG = "debug"
    INFO = "info"
    WARN = "warn"
    ERROR = "error"

# Exhaustive matching with match/case (Python 3.10+)
def handle(status: HttpStatus) -> str:
    match status:
        case HttpStatus.OK:          return "Success"
        case HttpStatus.NOT_FOUND:   return "Not found"
        case HttpStatus.SERVER_ERROR: return "Boom"

Pick the variant by use case: Enum for opaque tokens, IntEnum when values need to participate in arithmetic or compare with plain ints, StrEnum for string-comparable values (Python 3.11+), IntFlag for bitmask-style sets.

Which immutable collection types should you reach for?

Lists and dicts are mutable. For constant collections, prefer the immutable counterparts so accidental modification raises immediately:

  • Use tuple instead of list.
  • Use frozenset instead of set.
  • Wrap dicts with types.MappingProxyType for read-only views (Python 3.3+, 2012).
  • Use @dataclass(frozen=True) or NamedTuple for constant records with named fields.
python
from types import MappingProxyType
from dataclasses import dataclass

ALLOWED_EXTENSIONS = ("py", "pyi", "pyx")
RESERVED_WORDS = frozenset({"if", "else", "while", "for", "def", "class"})

_CONFIG = {"debug": False, "log_level": "info"}
CONFIG = MappingProxyType(_CONFIG)
# CONFIG["debug"] = True
# → TypeError: 'mappingproxy' object does not support item assignment

@dataclass(frozen=True)
class Limits:
    max_upload_mb: int
    max_concurrency: int

LIMITS = Limits(max_upload_mb=50, max_concurrency=100)
# LIMITS.max_upload_mb = 100
# → dataclasses.FrozenInstanceError

Which built-in constants should you know?

Python's standard library spreads constants across math, sys, string, and a handful of built-ins. The most useful in everyday code:

python
import math, sys, string

# Math
math.pi      # 3.141592653589793
math.e       # 2.718281828459045
math.tau     # 6.283185307179586  (2*pi — preferred since PEP 628)
math.inf     # Positive infinity
math.nan     # Not a Number — never compare with ==

# System / interpreter
sys.maxsize       # Largest list index (2**63 - 1 on 64-bit)
sys.float_info    # Float limits as a named tuple
sys.version_info  # (major, minor, micro, releaselevel, serial)

# String
string.ascii_lowercase  # 'abcdefghijklmnopqrstuvwxyz'
string.digits           # '0123456789'
string.punctuation      # all ASCII punctuation

# Built-ins
True, False
None
...          # Ellipsis — used in type stubs and slicing
__debug__    # True unless Python ran with -O

Practical patterns and gotchas

  • Don't trust bool() on string env vars. bool('false') returns True because the string is non-empty. Parse explicitly.
  • Module-level constants run once, at import time. If a "constant" is built from os.environ or a file read, it freezes the startup state — usually what you want, occasionally a footgun.
  • Final on classes prevents subclassing: @final from typing blocks inheritance at the type-check layer; : Final on a method blocks override.
  • StrEnum is the modern way to define string constants in Python 3.11+. Members compare equal to their string value, iterate cleanly, and serialize to JSON without a custom encoder.

For language-agnostic naming, immutability, and "magic number" rules, see constants best practices. For Python's environment variable story, see environment variables in Python.

Was this helpful?

Read next

Constants in JavaScript & TypeScript: const & as const

JS const blocks reassignment, not mutation. Real immutability comes from Object.freeze (ES5) or — better — TypeScript's as const assertion (TS 3.4, 2019) with zero runtime cost.

Continue →

Frequently Asked Questions

Does Python have a const keyword?

No. Python uses convention: variables in SCREAMING_SNAKE_CASE per PEP 8 are understood to be constants. Nothing in the language prevents reassignment, but linters (Ruff, Pylint) flag it, and typing.Final (PEP 591, Python 3.8) enables static type checkers like mypy and Pyright to enforce immutability at analysis time.

What is the difference between typing.Final and SCREAMING_SNAKE_CASE?

They complement each other. SCREAMING_SNAKE_CASE is a visual signal that humans and linters recognize. typing.Final is a static-type-checker hint with no runtime effect — mypy fails the build on reassignment but the program still runs. Use both together for the strongest signal.

When should I use Enum, IntEnum, or StrEnum?

Enum for opaque tokens that should not be compared to ints or strings. IntEnum when values participate in arithmetic or compare with plain ints (HTTP status codes). StrEnum (Python 3.11+) for string-comparable values that serialize cleanly to JSON. IntFlag for bitmask-style sets.

How do I make a Python dict read-only?

Wrap it with types.MappingProxyType (Python 3.3+, 2012). Reads work normally; writes raise TypeError. Pair with @dataclass(frozen=True) for constant records with named fields, or NamedTuple for tuple-style constant records.

Stay up to date

Get notified about new guides, tools, and cheatsheets.