TS-35: Python

This technical standard provides guidelines for writing Python code that is clear, maintainable, and consistent. It is based on PEP 8, the language’s own style guide, and Google’s Python Style Guide, supplemented with the typing-related PEPs and current community tooling conventions. Other sources are listed in the references section at the end of this document.

Terminology

Throughout this document, the following words shall have the following meanings:

  • "Module" means a single .py file.
  • "Package" means a directory of modules containing an init.py file (a "regular package") or, since Python 3.3, one without (a "namespace package"). This standard’s use of "package" always means the import-time construct, never a distributable unit published to a package index — that meaning is written out in full as "distribution package" wherever it is intended.
  • "Dunder" means a name with a double underscore on both ends, eg. init, repr. Short for "double underscore".
  • "Callable" means any object that can be invoked with () — a function, a method, a class, or an instance implementing call.
  • "Type hint" and "type annotation" are used interchangeably to mean the optional static-typing syntax introduced by PEP 484 and extended by later PEPs.

Source files

Source files are distinguished by the .py extension. Module names MUST follow the naming rules in Naming conventions: all lower case, with underscores if it improves readability, and MUST NOT collide with a standard library module name (eg. do not name a module json.py or types.py).

Encoding and line endings

Source files MUST be encoded using UTF-8, with no byte order mark. Python 3 assumes UTF-8 source by default; an explicit # -- coding: utf-8 -- declaration is unnecessary and SHOULD NOT be added.

Source files MUST use Unix-style line endings (LF, \n), not Windows-style (CRLF, \r\n).

Indentation MUST use 4 spaces per level. Tab characters MUST NOT be used for indentation, and MUST NOT be mixed with spaces — Python 3 raises a TabError if a file mixes the two inconsistently, but a mix that happens to be consistent within one file is still a readability hazard for anyone whose editor renders tabs at a different width.

Line length

Lines SHOULD be wrapped at 88 characters, the default enforced by Black and adopted by Google’s Python Style Guide. This is a deliberate departure from PEP 8’s stricter 79-character limit: 88 characters better matches modern wide-screen editors while still comfortably supporting two files side by side, and is now the more common convention in new Python codebases. A project MAY instead adopt PEP 8’s 79-character limit if it must interoperate closely with a codebase that already enforces it, but MUST NOT mix the two limits within a single project.

Docstrings and comments SHOULD be wrapped more conservatively, at 72 characters, matching PEP 8’s recommendation for prose.

Module layout

A module’s contents MUST be laid out in the following order, from top to bottom:

  1. Module docstring (see Docstrings).
  2. from future imports, if any.
  3. Standard library imports.
  4. Third-party imports.
  5. Local application/library imports.
  6. Module-level "dunder" attributes (all, version), if any.
  7. Module-level constants.
  8. Classes and functions.
  9. A if name == "main": guard, if the module is also runnable as a script.

Each of the three import groups (stdlib, third-party, local) MUST be separated from the others by a single blank line, and the imports within each group SHOULD be sorted alphabetically. isort, or an equivalent formatter, SHOULD be used to enforce this automatically rather than maintained by hand.

"""Utilities for parsing and validating inbound webhook payloads."""

from __future__ import annotations

import hashlib
import json
from typing import Any

import requests
from pydantic import BaseModel

from myapp.errors import ValidationError
from myapp.settings import get_settings

MAX_PAYLOAD_BYTES = 1_048_576

Wildcard imports (from module import *) MUST NOT be used, except in the init.py of a package that is deliberately re-exporting a curated public API — and even then, the re-exported names SHOULD be listed explicitly in all rather than left implicit.

Relative imports (from . import sibling, from ..pkg import thing) MAY be used within a package, but MUST NOT reach more than one level up (from …​pkg import thing and deeper). A module that needs to reach further up the package tree SHOULD use an absolute import instead.

Module size

A single module SHOULD NOT exceed roughly 1,000 lines. This is a soft guideline, not a hard limit — the underlying signal is whether the module still has one clear responsibility, and a module can earn its length by covering one cohesive area. Once a module accumulates unrelated responsibilities, it SHOULD be split along those responsibility boundaries, usually into a package (a directory with an init.py) containing several smaller modules.

Naming conventions

Spelling

All file names, code, comments, and docstrings MUST be written in English, with American English preferred for spelling.

Modules and packages

Modules MUST be named using lower_snake_case, eg. payment_gateway.py. Short, all-lowercase names with no underscore are also acceptable where the name is a single word, eg. errors.py.

Packages (directories containing an init.py) MUST be named using all lower case, and SHOULD avoid underscores where a single concatenated word is readable, eg. webapi, matching the convention recommended by PEP 8. This differs from module naming, where underscores are preferred for multi-word names — the distinction mirrors PEP 8’s own guidance that package names should stay short.

Classes and exceptions

Classes MUST be named using UpperCamelCase, eg. PaymentGateway, UserRepository.

Class names are typically nouns or noun phrases. They SHOULD be descriptive and unambiguous, and SHOULD NOT be overly long.

Exception classes MUST be named using UpperCamelCase and MUST end with the suffix Error, eg. ValidationError, PaymentDeclinedError — not Exception, which is too generic to be useful in a except SomeException: clause, and not the bare noun without a suffix, which reads ambiguously against non-exception classes of the same name.

Functions, methods, and variables

Functions, methods, and variables (including local variables and instance attributes) MUST be named using lower_snake_case, eg. send_message, compute_total, user_count.

Function and method names SHOULD, typically, be verbs or verb phrases. Variable names SHOULD, typically, be nouns or noun phrases.

A single leading underscore (_internal_helper) MUST be used to signal that a name is internal to a module or class and not part of its public interface. This is a convention only — Python does not enforce it — but it MUST be honored by callers: code outside the module or class MUST NOT reference a single-underscore name directly.

A double leading underscore with no trailing underscore (__really_private) invokes Python’s name-mangling within a class body. This SHOULD be reserved for the narrow case where a subclass accidentally overriding an attribute would break the base class — it is a mechanism for avoiding name collisions in an inheritance hierarchy, not a stronger form of "private", and MUST NOT be used as the default way to mark internal attributes (use a single underscore for that).

A single trailing underscore (class_, type_) MAY be used to avoid colliding with a Python keyword or a built-in name, in preference to misspelling the identifier (klass) or shadowing the built-in.

Dunder names (init, repr, eq) are reserved for the interpreter’s own protocol methods and MUST NOT be invented for application-level names — defining a new dunder to mean something the language does not already give it a meaning for is confusing to any reader who has learned the standard set.

One- or two-character names SHOULD be reserved for small scopes with an obvious, conventional meaning: loop counters (i, j), coordinates (x, y), or a comprehension’s throwaway variable (_). They SHOULD NOT be used for anything with a lifetime longer than a few lines or a scope wider than a single function body.

Constants

Module-level constants MUST be named using UPPER_SNAKE_CASE, eg. MAX_RETRIES, DEFAULT_TIMEOUT_SECONDS.

As in TS-33: Java's definition, a "constant" here means a module-level value that is set once, at import time, and never reassigned. Python has no const keyword, so this is a naming convention only, signaling intent to the reader rather than an enforced guarantee.

MAX_RETRIES = 3
DEFAULT_TIMEOUT_SECONDS = 30.0
SUPPORTED_CURRENCIES = frozenset({"USD", "EUR", "GBP"})

Type variable names

Type variables, declared with typing.TypeVar or the class Foo[T] / def foo[T] generic syntax (Python 3.12+), SHOULD be named with a single capital letter, optionally followed by a digit, eg. T, KT, VT, following the convention established by the typing module itself.

Code style

Automated formatting

Code formatting — indentation, blank lines, line wrapping, quote-character choice, trailing commas — MUST be delegated to an autoformatter, run in CI and as a pre-commit hook, rather than maintained by hand or enforced through code review. Black or Ruff’s formatter (which is Black-compatible) are RECOMMENDED. This standard’s line-length and blank-line rules describe what such a formatter already does by default; they are stated here so the standard is self-contained, not as an instruction to apply them manually.

A project MUST pick one formatter and apply it uniformly. Reformatting the entire codebase in one commit when adopting a formatter, isolated from any functional change, is RECOMMENDED, so that git blame is not muddied by mixing the reformat with unrelated diffs.

Blank lines

Two blank lines MUST separate top-level function and class definitions. One blank line MUST separate method definitions within a class. A single blank line MAY be used sparingly within a function body to group related statements.

import os


class PaymentGateway:
    def __init__(self, api_key: str) -> None:
        self.api_key = api_key

    def charge(self, amount: int) -> None:
        ...


def main() -> None:
    ...

Whitespace

A single space MUST surround binary operators (x = a + b, not x=a+b), with the exception of keyword arguments and default parameter values in a function signature, which MUST NOT be surrounded by spaces when unannotated (def f(x=1):), but MUST be surrounded by spaces when the parameter carries a type annotation (def f(x: int = 1):) — following PEP 8’s rule that the = here is read as part of the annotated declaration, not a bare assignment.

No space MUST appear immediately inside parentheses, brackets, or braces (spam(ham[1], {eggs: 2}), not spam( ham[ 1 ], { eggs: 2 } )), and no space MUST appear before a comma, semicolon, or colon, but a space MUST follow one (except at the end of a line).

Trailing commas

A trailing comma SHOULD be included after the last element of a multi-line list, tuple, dict, set, or function call/signature. This keeps the diff to a single line when a further element is added later, and is what Black inserts automatically in "magic trailing comma" mode.

SUPPORTED_REGIONS = [
    "us-east-1",
    "eu-west-1",
    "ap-southeast-2",
]

A single-line collection or call MUST NOT carry a trailing comma before the closing bracket, except the one-element tuple, where the trailing comma is mandatory syntax ((value,)), not a style choice.

String quotes

Single and double quotes are equally acceptable for string literals; PEP 8 takes no position, and this standard does not either — a project SHOULD adopt whichever its autoformatter enforces (Black defaults to double quotes) and apply it consistently, rather than mixing both by hand. Whichever is chosen, use the other quote character to avoid backslash-escaping a quote that appears inside the string, per PEP 8.

Triple double-quotes (""") MUST be used for docstrings, regardless of which quote style is chosen for ordinary string literals — see Docstrings.

f-strings (f"{value}") SHOULD be preferred over %-formatting or .format() for constructing strings that interpolate values, for readability and because they are evaluated at the point of use rather than requiring positional or keyword argument matching.

Comparisons

Singletons (None, True, False) MUST be compared with is / is not, never == / !=.

# ✓
if value is None:
    ...

# ✗
if value == None:
    ...

Emptiness of a sequence, string, or collection SHOULD be tested with an implicit boolean check (if not items:), not an explicit length comparison (if len(items) == 0:), unless the distinction between "empty" and "falsy for another reason" genuinely matters for the value in question — for example, a value that could be either None or 0, where collapsing both to "falsy" would be a bug.

Type checks SHOULD use isinstance(), not a direct comparison of type(), because isinstance() respects subclassing.

# ✓
if isinstance(value, int):
    ...

# ✗
if type(value) == int:
    ...

Programming constructs

EAFP over LBYL

Python idiom generally prefers "Easier to Ask Forgiveness than Permission" (EAFP) — attempt the operation and handle the exception — over "Look Before You Leap" (LBYL) — check preconditions before attempting the operation. EAFP SHOULD be preferred where the failure case is a routine, expected outcome (a missing dictionary key, a file that may not exist, a type that may not support an operation), because it avoids a race condition between the check and the use, and because the CPython interpreter is optimized for the successful path.

# ✓ EAFP
try:
    value = mapping[key]
except KeyError:
    value = default

# ✗ LBYL — has a race, and reads worse
if key in mapping:
    value = mapping[key]
else:
    value = default

LBYL MAY still be preferred where the check is cheap, the failure path would be expensive or side-effecting to trigger deliberately, or where an explicit guard clause reads more clearly than a broad exception handler.

Comprehensions and generator expressions

A list, dict, or set comprehension SHOULD be preferred over an equivalent for loop with .append() when the body is a single, simple expression.

# ✓
squares = [n * n for n in range(10)]

# ✗ — same result, more ceremony
squares = []
for n in range(10):
    squares.append(n * n)

A comprehension SHOULD be abandoned in favor of an explicit for loop once it needs a second for clause beyond simple flattening, a non-trivial if condition, or any side effect — a comprehension with a side effect (calling a function for its effect rather than its return value) is a code smell, since the construct exists to build a collection, not to execute statements.

A generator expression ((x for x in iterable), no brackets) SHOULD be preferred over a list comprehension when the result will only be iterated once and does not need to be indexed, reused, or measured with len() — it avoids materializing the whole collection in memory.

Context managers

Anything that acquires a resource requiring explicit release — a file handle, a network connection, a lock, a database transaction — MUST be managed with a with statement, not a manual acquire()/release() or open()/close() pair. The with statement guarantees the resource is released even if the body raises.

# ✓
with open(path, encoding="utf-8") as f:
    contents = f.read()

# ✗ — leaks the handle if read() raises
f = open(path, encoding="utf-8")
contents = f.read()
f.close()

A class that manages its own resource SHOULD implement the context manager protocol (enter/exit) rather than exposing separate open/close-style methods, so its callers get the same with-statement guarantee. contextlib.contextmanager SHOULD be used to implement a simple context manager as a generator function, in preference to hand-writing a class with enter/exit when no other state needs to live on the object.

Mutable default arguments

A mutable object (a list, dict, or set) MUST NOT be used as a default argument value. Default argument values are evaluated once, at function definition time, not on each call — so a mutable default is silently shared and mutated across every call that relies on it, which is very rarely the intended behavior.

# ✗ — the same list is reused and grows across calls
def add_item(item, items=[]):
    items.append(item)
    return items

# ✓
def add_item(item, items=None):
    if items is None:
        items = []
    items.append(item)
    return items

Unpacking

Tuple/iterable unpacking SHOULD be used in preference to indexed access when assigning several related values at once, including the starred form for "the rest of the values".

first, *middle, last = values

Swapping two variables SHOULD use tuple unpacking (a, b = b, a), not a temporary variable.

Loops

enumerate() SHOULD be used instead of manually tracking an index with range(len(…​)).

# ✓
for index, item in enumerate(items):
    ...

# ✗
for index in range(len(items)):
    item = items[index]
    ...

zip() SHOULD be used to iterate over two or more sequences in lockstep, instead of indexing each by a shared counter.

A for/else or while/else construct MAY be used where its semantics (the else block runs only if the loop completed without break) genuinely fit the logic, but authors SHOULD add a short comment noting that the else is loop-else, not the more commonly expected if-else — this construct is unfamiliar enough that an unmarked use is a readability tax on the reader.

Error handling

Exception handlers MUST catch the narrowest exception type that is actually expected, never a bare except: and, except at a genuine top-level boundary (a request handler, a task runner) that must not crash regardless of cause, not a bare except Exception: either.

Custom exceptions SHOULD be defined for a library or module’s own error conditions, subclassing the most specific applicable built-in exception (or Exception directly if none fits), so callers can distinguish this code’s failures from unrelated ones.

Where an exception is re-raised after partial handling — logging it, adding context, or translating it to a different type — raise …​ from err SHOULD be used to preserve the original traceback as the new exception’s cause, rather than losing it or, worse, swallowing it with a bare raise NewError() that discards where the failure actually originated.

try:
    parse(payload)
except ValueError as err:
    raise ValidationError("invalid payload") from err

Types and typing

Type hints

Every public function, method, and module-level variable MUST carry type hints for its parameters and return value, per PEP 484 and PEP 526. Private (single-underscore) helpers SHOULD also be annotated, but the requirement is stricter for public interfaces because they are the contract other code depends on.

def calculate_total(items: list[Item], tax_rate: float) -> Decimal:
    ...

The builtin generic syntax (list[Item], dict[str, int]) MUST be used instead of the deprecated typing.List, typing.Dict, etc., which are retained only for backward compatibility with Python versions predating PEP 585 (3.9). A project supporting an older Python version MUST use the typing equivalents, or from future import annotations to defer evaluation and use the builtin syntax anyway — see Deferred evaluation of annotations.

A value that may be absent MUST be annotated as X | None (Python 3.10+, per PEP 604) or the equivalent Optional[X] on earlier versions — never left as a bare X with an implicit None default silently tolerated by the caller. A function returning X | None signals to every caller that the None case MUST be handled.

Any SHOULD be avoided. It disables type checking for the value it is applied to and for everything derived from it, so its use SHOULD be confined to genuine boundary cases — deserializing untrusted external data before it has been validated into a concrete shape, or interoperating with an untyped third-party library — and MUST NOT be used merely to silence a type checker error that reflects a real type mismatch.

Static type checking

A static type checker — mypy or pyright — SHOULD run in CI against the whole codebase, not merely on a subset of files, and its failures SHOULD block the build. Where a codebase is being migrated to typed code incrementally, the checker MAY be configured to only enforce strict checking on already-annotated modules, with a plan to widen that scope over time, rather than left permanently partial.

Type-checking suppressions (# type: ignore) MUST carry the specific error code being suppressed (# type: ignore[assignment]) and a brief comment explaining why, so the suppression doesn’t silently swallow an unrelated future error in the same line.

Deferred evaluation of annotations

from future import annotations (per PEP 563) SHOULD be added at the top of a module that uses type hints, so that annotations are stored as strings and evaluated lazily rather than at function-definition time. This allows the newer X | None union syntax and builtin generic syntax to be used even on Python versions that would otherwise require typing.Optional and typing.List, and avoids a class needing to forward-reference its own name in a method signature.

Structured data

A dataclass (@dataclasses.dataclass) SHOULD be used, rather than a bare dict, tuple, or hand-written class with a manual init, whenever a piece of code needs a record type with named fields.

from dataclasses import dataclass


@dataclass(frozen=True)
class Money:
    amount: int
    currency: str

frozen=True SHOULD be set unless the dataclass genuinely needs to be mutated after construction — an immutable record is easier to reason about and safe to share across threads.

typing.NamedTuple MAY be preferred over a dataclass where tuple semantics (unpacking, positional access, hashability by default) are actually wanted; a plain, untyped tuple or collections.namedtuple SHOULD NOT be used for a new record type where either of the typed alternatives is available.

A TypedDict SHOULD be used, rather than an untyped dict[str, Any], to describe the shape of dictionary data with a fixed, known set of string keys — most commonly, JSON payloads deserialized from an external API.

Protocols (typing.Protocol) SHOULD be used to define a structural interface — "anything with this method signature" — in preference to an abstract base class, when the point is to accept any object with the right shape rather than to share implementation via inheritance.

Documentation and comments

Docstrings

Every public module, class, function, and method MUST have a docstring, per PEP 257. A private (single-underscore) function SHOULD have one if its behavior is not obvious from its name and signature alone.

Docstrings MUST use triple double-quotes ("""), even for a one-line docstring, for consistency and because triple-quoting is required as soon as the docstring spans multiple lines.

A one-line docstring MUST fit on a single line, phrased as a command ("Return the total.", not "Returns the total." or "This function returns the total."), and the closing """ MUST sit on the same line as the opening one.

def is_valid(value: str) -> bool:
    """Return True if value is a syntactically valid account ID."""
    ...

A multi-line docstring MUST have a one-line summary on the first line, followed by a blank line, followed by further detail. The closing """ MUST sit on its own line.

This standard adopts Google-style docstring sections — Args:, Returns:, Raises: — over the alternative reStructuredText (:param:, Sphinx-native) or NumPy styles, because they read cleanly as plain text even before any documentation generator processes them.

def charge(account_id: str, amount: Decimal) -> Receipt:
    """Charge the given account and return a receipt.

    Args:
        account_id: The account to charge, in "acct_<id>" form.
        amount: The amount to charge, in the account's home currency.

    Returns:
        A Receipt recording the transaction ID and settled amount.

    Raises:
        InsufficientFundsError: If the account balance is too low.
        AccountNotFoundError: If account_id does not exist.
    """
    ...

A class’s docstring MUST describe the class’s purpose and, where it carries public attributes not already documented via dataclass field types or property docstrings, MUST document them under an Attributes: section following the same style as Args:.

A module’s docstring — the first statement in the file, before any import — MUST summarize what the module contains, in enough detail that a reader can decide whether to open it without doing so.

Comments

Comments MUST explain why the code does something non-obvious, not what the code does when that is already clear from reading it. A comment that merely restates the following line in English adds noise, not information.

# ✓ — explains a non-obvious constraint
retries = 3  # the upstream gateway rate-limits after 3 attempts/second

# ✗ — restates the code
retries = 3  # set retries to 3

Inline comments (on the same line as code) SHOULD be used sparingly, separated from the code by at least two spaces, and SHOULD be reserved for a short clarification that doesn’t warrant its own line.

Block comments SHOULD be complete sentences, with the first word capitalized (unless it is an identifier that begins with a lowercase letter), and SHOULD be kept up to date — a comment that describes behavior the code no longer has is worse than no comment, since it actively misleads the next reader.

# TODO(username): description SHOULD be used to mark unfinished work, following the convention recommended by Google’s Python Style Guide, with the username identifying who to ask for context — not necessarily who will do the work.

# TODO(kieran): remove once the legacy v1 endpoint is retired.

Commented-out code MUST NOT be committed. Version control already preserves history; a block of dead code left in a comment only accumulates and rots.

Project structure and tooling

Project metadata

Every Python project MUST declare its metadata and dependencies in pyproject.toml, per PEP 621 — not the legacy setup.py / setup.cfg combination, which SHOULD only be retained where a build backend genuinely requires it (eg. a package with a compiled extension using a build backend that has not yet adopted PEP 621).

A lock file — generated by whichever dependency manager the project uses (eg. uv.lock, poetry.lock) — MUST be committed alongside pyproject.toml for any application (as opposed to a library intended for reuse under a range of dependency versions), so that every environment installs identical dependency versions.

Virtual environments

Dependencies MUST be installed into an isolated virtual environment, never into the system-wide interpreter. uv, Poetry, or the standard library’s venv module are all acceptable; uv is RECOMMENDED for new projects for its speed and because it unifies environment creation, dependency resolution, and locking in one tool.

A committed .python-version file (or the equivalent pin in pyproject.toml) SHOULD specify the exact interpreter version a project targets, so that contributors and CI resolve to the same version rather than whatever happens to be on PATH.

Package layout

A distributable library SHOULD use the "src layout" — application code under src/<package_name>/, with tests in a sibling top-level tests/ directory — rather than a "flat layout" with the package at the repository root. The src layout prevents the package from being accidentally importable from the repository root without being installed, which catches packaging mistakes (a missing entry in the package manifest) that a flat layout would hide during local development.

project/
├── pyproject.toml
├── uv.lock
├── src/
│   └── myapp/
│       ├── __init__.py
│       └── ...
└── tests/
    └── ...

A standalone application that is deployed rather than distributed as an installable package (eg. a service deployed as a container image) MAY use a flat layout instead, since the packaging-mistake risk the src layout guards against does not apply the same way.

Linting, formatting, and type checking

Ruff is RECOMMENDED as the project’s linter, covering the majority of what flake8, isort, pyupgrade, and several other single-purpose tools historically covered separately, and MAY also be used as the project’s formatter (see Automated formatting) in place of Black. Where both Ruff’s formatter and Black are viable, either is acceptable; a project MUST NOT run both, since they can disagree on edge cases.

Static type checking (see Static type checking) SHOULD run as a separate step from linting — mypy or pyright are not a substitute for Ruff, nor Ruff for them, since Ruff performs no type inference.

Linting, formatting (in check mode), and type checking SHOULD each run as a required CI check, and SHOULD also be available locally as pre-commit hooks so that violations are caught before a commit is pushed, not only in CI.

Testing

pytest is RECOMMENDED over the standard library’s unittest for new test suites, for its plain assert-based assertions, fixture system, and parametrization support. A project already built on unittest MAY continue using it — pytest can run unittest-style test cases unmodified — rather than being forced through a disruptive rewrite.

Test files MUST be named test_<module>.py or <module>_test.py (consistently, whichever a project chooses) so that pytest’s default discovery picks them up without additional configuration.


References