Python Coding Best Practices: Pythonic Idioms & Techniques

Blog / Python · July 6, 2021 · Updated June 10, 2026 · 11 min read
Python Coding Best Practices: Pythonic Idioms & Techniques

Writing code that runs is only half the job. Writing code that another developer — including future you — can read, extend, and debug six months later is what separates a throwaway script from software a team can maintain for years. Pythonic code leans on the language's own idioms instead of fighting them: it is shorter, clearer, and usually faster because it hands the heavy lifting to optimised built-ins.

This guide collects the coding techniques and practices we rely on at MicroPyramid after 12+ years and 50+ delivered projects. Every example targets the modern Python 3.12 / 3.13 era — no Python 2 idioms, no legacy cruft.

Run import this in a REPL and Python tells you what it values:

Beautiful is better than ugly. Explicit is better than implicit. Simple is better than complex. Readability counts.

Two practices flow directly from that Zen:

  • Readability first — code is read far more often than it is written, so optimise for the reader.
  • Maintainability — small, well-named, single-purpose pieces are cheap to change, test, and debug.

EAFP vs LBYL

Python prefers EAFPEasier to Ask Forgiveness than Permission: attempt the operation and handle the exception if it fails, rather than LBYL (Look Before You Leap), where you guard with checks first. EAFP avoids race conditions and usually reads cleaner.

# LBYL: check first, then act (race-prone, noisier)
if "email" in user and user["email"]:
    send(user["email"])

# EAFP: just try it, handle the miss (Pythonic)
try:
    send(user["email"])
except KeyError:
    pass

Readability and PEP 8

PEP 8 is the style guide every Python team converges on. You rarely apply it by hand any more — a formatter does that for you (see Tooling below) — but you still choose the names, and names carry the meaning.

  • snake_case for variables, functions, methods, and modules.
  • PascalCase (CapWords) for classes.
  • UPPER_SNAKE_CASE for constants.
  • A single leading underscore (_internal) signals "private by convention".
  • Skip type prefixes and negative names like is_not_ready — prefer is_ready.

Note that camelCase (common in Java or JavaScript) is not Pythonic for variables and functions — Python uses snake_case.

# Unclear, non-Pythonic names
def calc(x, l):
    return [i * x for i in l]

# Intent-revealing, PEP 8 names
TAX_RATE = 0.2

def apply_tax(prices: list[float]) -> list[float]:
    return [price * (1 + TAX_RATE) for price in prices]

Loop like a Pythonist

The clearest giveaway of code ported from another language is manual index juggling: for i in range(len(items)). Python lets you iterate over the items themselves, and gives you enumerate, zip, reversed, and sorted for the cases where you need more.

colours = ["red", "green", "blue", "yellow"]

# C-style: index the list by position
for i in range(len(colours)):
    print(colours[i])

# Pythonic: iterate the items directly
for colour in colours:
    print(colour)

# Need the index too? Use enumerate
for i, colour in enumerate(colours):
    print(i, "-->", colour)

# Walk two sequences together with zip
names = ["Anji", "Ben", "Catherin"]
for name, colour in zip(names, colours):
    print(name, "-->", colour)

# reversed() and sorted() read straight off
for colour in reversed(colours):
    print(colour)

for colour in sorted(colours, reverse=True):
    print(colour)

# Iterate a dict's pairs with .items()
prices = {"pen": 2, "book": 8}
for item, price in prices.items():
    print(item, price)

Comprehensions and generator expressions

A comprehension expresses "build a collection from an iterable" in one readable line, and runs faster than the equivalent append loop. Use them when the logic is simple; fall back to a plain loop when it would otherwise get cramped. Wrapping the expression in () instead of [] gives a lazy generator expression that streams values one at a time — ideal for large data.

numbers = [2, 7, 5, 4, 6, 1, 8, 9, 3]

# Loop + append
result = []
for n in numbers:
    if n % 3 == 0:
        result.append(n)

# List comprehension (same result, one line)
result = [n for n in numbers if n % 3 == 0]

# dict and set comprehensions
squares = {n: n * n for n in numbers}
unique_evens = {n for n in numbers if n % 2 == 0}

# Generator expression: lazy, memory-friendly for huge inputs
total = sum(n * n for n in numbers)

f-strings, unpacking, and the walrus operator

f-strings (3.6+) are the one true way to format strings — readable and fast. The = specifier (3.8+) is a debugging gift. Iterable unpacking and star-expressions remove index access, and the walrus operator := (3.8+) assigns inside an expression so you don't compute a value twice.

name, role = "Ada", "admin"

# f-string formatting
print(f"{name} signed in as {role}")
print(f"{name=}, {role=}")          # -> name='Ada', role='admin'  (debug)
print(f"{1 / 3:.2f}")               # -> 0.33

# Unpacking with a star-expression
first, *rest = [1, 2, 3, 4]         # first=1, rest=[2, 3, 4]

x, y = 1, 2
x, y = y, x                         # swap without a temp variable

# Ternary expression (comparisons already return a bool)
status = "ok" if role == "admin" else "denied"

# Walrus: assign and test in one expression
if (extra := len(rest)) > 2:
    print(f"{extra} extra items")

Structural pattern matching

match/case (Python 3.10+) is far more than a C switch: it destructures data and binds variables while it matches. It shines when handling shapes of JSON, events, or commands — and the old dict-as-switch trick is no longer needed.

def handle(event: dict) -> str:
    match event:
        case {"type": "click", "x": x, "y": y}:
            return f"click at {x},{y}"
        case {"type": "key", "code": code}:
            return f"key {code}"
        case {"type": kind}:
            return f"unhandled: {kind}"
        case _:
            return "malformed event"

Context managers and pathlib

A with block guarantees cleanup — closing files, releasing locks — even when an error is raised, so never rely on a manual close(). For filesystem work, pathlib replaces the string juggling of os.path with readable, OS-independent path objects.

from pathlib import Path

# Open a file safely; it is closed automatically on exit
config = Path("settings") / "app.cfg"
if config.exists():
    with config.open(encoding="utf-8") as f:
        data = f.read()

# pathlib reads far cleaner than os.path.join(...) chains
for py_file in Path("src").rglob("*.py"):
    print(py_file.name, py_file.stat().st_size)

Type hints and static checking

Type hints (PEP 484) document intent and let tools catch whole classes of bugs before the code runs. Since Python 3.9 (PEP 585) you annotate with the built-in generics list[int] and dict[str, int] — no more typing.List. Since 3.10 (PEP 604) unions are written str | None instead of Optional[str].

Crucially, hints do not run. The interpreter ignores them at runtime, so they cost nothing in speed. Their value comes from a static checker like mypy or pyright (the engine behind VS Code's Pylance) reading the annotations and flagging mismatches in your editor and CI.

USERS: dict[int, dict[str, str]] = {}

def total_price(items: list[float], discount: float = 0.0) -> float:
    return sum(items) * (1 - discount)

def find_user(user_id: int) -> dict[str, str] | None:
    return USERS.get(user_id)

# mypy / pyright catch this without ever running the program:
# total_price("not a list")   # error: expected list[float]

Model data with dataclasses, enums, and pydantic

Stop passing bare dicts and tuples around for structured data. A @dataclass (3.7+) gives you __init__, __repr__, and equality for free from type-annotated fields. An Enum names a fixed set of choices so you never compare against magic strings. When data crosses a trust boundary — an API request, environment config — reach for pydantic, which validates and coerces at runtime and powers FastAPI request models and pydantic-settings.

from dataclasses import dataclass, field
from enum import Enum

class Role(Enum):
    ADMIN = "admin"
    EDITOR = "editor"
    VIEWER = "viewer"

@dataclass
class User:
    name: str
    role: Role = Role.VIEWER
    tags: list[str] = field(default_factory=list)

user = User("Ada", Role.ADMIN)
print(user)   # User(name='Ada', role=<Role.ADMIN: 'admin'>, tags=[])

Functions: small, pure, and trap-free

Keep functions short and single-purpose — a function you can describe in one sentence is one you can test. A few rules pay off constantly:

  • Never use a mutable default argument. def f(x=[]) creates the list once, at definition time, and every call shares it — a classic bug. Default to None and build inside.
  • Use keyword-only arguments (after a bare *) for booleans and options so call sites stay self-documenting.
  • Prefer explicit arguments and return values over reading and mutating globals.
# BUG: the default list is created once and shared across calls
def add_item(item, basket=[]):
    basket.append(item)
    return basket

add_item("a")   # ['a']
add_item("b")   # ['a', 'b']  <- surprise: state leaked between calls

# FIX: sentinel default, build a fresh list inside
def add_item(item, basket: list | None = None) -> list:
    basket = [] if basket is None else basket
    basket.append(item)
    return basket

# Keyword-only options read clearly at the call site
def export(rows, *, dry_run: bool = False, fmt: str = "csv") -> None:
    ...

export(rows, dry_run=True, fmt="json")

Errors, exceptions, and logging

Catch the specific exception you expect — never a bare except: that swallows everything (including KeyboardInterrupt and genuine bugs). Define custom exceptions for your domain, use the full try / except / else / finally when it clarifies intent, and log through the logging module instead of print so output has levels, timestamps, and configurable destinations.

import logging

logger = logging.getLogger(__name__)

class PaymentError(Exception):
    """Raised when a charge cannot be completed."""

def charge(account, amount: float) -> bool:
    try:
        result = gateway.charge(account, amount)
    except TimeoutError:
        logger.warning("gateway timeout for %s", account)
        return False
    except PaymentError:
        logger.exception("charge failed")    # logs the traceback at ERROR
        raise
    else:
        logger.info("charged %.2f to %s", amount, account)
        return result
    finally:
        gateway.close()

For reusable setup/teardown, write your own context manager with contextlib.contextmanager instead of repeating try/finally everywhere:

from contextlib import contextmanager
import time

@contextmanager
def timer(label: str):
    start = time.perf_counter()
    try:
        yield
    finally:
        logger.info("%s took %.3fs", label, time.perf_counter() - start)

with timer("import"):
    run_import()

Lean on built-ins, itertools, and collections

Before writing a loop, check whether the standard library already does the job — its C implementations are faster and battle-tested. sum, min, max, any, all, and sorted cover most aggregations. The collections module (Counter, defaultdict, deque) and itertools (chain, groupby, accumulate, pairwise) replace fiddly manual bookkeeping. And map/filter now return lazy iterators in Python 3 — wrap them in list() only when you actually need a concrete list.

from collections import Counter
from itertools import pairwise

words = "the cat sat on the mat".split()

# Tally occurrences without a manual dict
counts = Counter(words)            # Counter({'the': 2, 'cat': 1, ...})
print(counts.most_common(1))       # [('the', 2)]

# map/filter are lazy iterators in Python 3 — materialise when needed
totals = list(map(int, ["1", "2", "3"]))

# itertools.pairwise (3.10+): sliding windows of two
for a, b in pairwise([1, 2, 3, 4]):
    print(a, b)                    # 1 2 / 2 3 / 3 4

The Python toolchain

Modern Python teams have largely standardised their tooling, and the headline change of the last few years is ruff — an extremely fast linter and formatter written in Rust that collapses the old flake8 + isort + (often) black stack into one tool. Pair it with a type checker, a fast environment manager, and a single pyproject.toml, and most style and quality enforcement becomes automatic.

Job 2026 default What it replaces
Lint + import sort ruff flake8, isort, pylint
Code formatter ruff format (or black) autopep8, yapf
Type checking mypy or pyright
Env + packaging uv pip, virtualenv, pip-tools, much of poetry
Test runner pytest unittest boilerplate
Commit-time gate pre-commit manual, ad-hoc checks

One pyproject.toml configures the lot:

# pyproject.toml
[project]
name = "myapp"
requires-python = ">=3.12"

[tool.ruff]
line-length = 100

[tool.ruff.lint]
select = ["E", "F", "I", "UP"]   # pycodestyle, pyflakes, import-sort, pyupgrade

[tool.mypy]
strict = true

A typical local loop then looks like:

  • uv venv && uv pip install -e . — create an isolated environment and install the project.
  • ruff check --fix . then ruff format . — lint, autofix, and format.
  • mypy . (or pyright) — static type check.
  • pytest — run the test suite.
  • pre-commit run --all-files — run all of the above as a gate before each commit.

Putting it together

None of these techniques are about cleverness — they are about writing code the next person can read and change safely. Adopt the idioms, let ruff and mypy enforce the boring parts, model your data explicitly, and handle errors deliberately. The result is software that ships faster and breaks less.

To go deeper on specific Python features, these companion guides build on the ideas here:

Working on a Python codebase that needs a steadier hand? Our team has shipped 50+ projects over 12+ years — see our Python development services.

Frequently Asked Questions

What does "Pythonic" mean?

Pythonic code solves a problem using Python's own idioms and built-ins rather than patterns carried over from other languages. In practice that means iterating over items instead of indexes, using comprehensions, f-strings, context managers, and the standard library — code that is shorter, clearer, and usually faster because it leans on optimised internals.

Should I use EAFP or LBYL?

Python favours EAFP — Easier to Ask Forgiveness than Permission — where you attempt an operation and catch the exception if it fails. It avoids the race conditions of LBYL (checking first, then acting) and generally reads cleaner. Use LBYL only when the check is cheap and a failure would be genuinely exceptional or expensive.

Do type hints slow Python down?

No. Type hints are ignored by the interpreter at runtime, so they have no measurable effect on execution speed. Their value is entirely at development time: tools like mypy and pyright read them to catch type bugs in your editor and CI before the code ever runs.

ruff or black — which should I use?

ruff is the 2026 default because it is a single, very fast tool that lints, sorts imports, and formats, replacing flake8, isort, and increasingly black. Its formatter, ruff format, is intentionally black-compatible, so teams already on black can switch with near-identical output. If your team is happy with black, running it alongside ruff's linter is also perfectly fine.

Why is a mutable default argument dangerous?

Because Python evaluates default arguments once, when the function is defined — not on each call. A list created as a default in def f(x=[]) is therefore shared across every call, so items accumulate between calls and cause subtle bugs. Default to None and create a fresh list or dict inside the function body instead.

dataclass or pydantic?

Use a dataclass for plain internal data containers where you trust the inputs — it is in the standard library and adds no runtime validation overhead. Use pydantic when data crosses a trust boundary (API payloads, configuration, user input) and you need runtime validation, parsing, and coercion; it is the basis of FastAPI request models and pydantic-settings.

Share this article