Best Practices

Overview

Writing Pythonic code means embracing the language’s philosophy and idioms rather than importing patterns from other languages. Python’s design prioritizes readability, explicitness, and simplicity. These best practices emerge from decades of community experience and align with “The Zen of Python” (PEP 20).

Core Pythonic Principles

EAFP Over LBYL

Easier to Ask for Forgiveness than Permission (EAFP) embodies Python’s pragmatic approach. Try operations and handle exceptions rather than checking conditions upfront.

Why it matters:

  • More readable by focusing on the happy path
  • Avoids race conditions in concurrent code
  • Handles edge cases you might not anticipate
  • Aligns with duck typing philosophy

Example:

def get_user_age(user_data):
    if user_data is not None:
        if 'age' in user_data:
            if isinstance(user_data['age'], int):
                if user_data['age'] >= 0:
                    return user_data['age']
    return None

def get_user_age(user_data):
    try:
        age = user_data['age']
        if age < 0:
            raise ValueError("Age cannot be negative")
        return age
    except (KeyError, TypeError):
        return None

Trade-offs:

  • EAFP is faster when exceptions are rare (the common case)
  • LBYL makes sense when exceptions are expensive in tight loops
  • Use EAFP as default, LBYL only with profiling evidence

Duck Typing and Protocols

Focus on what an object can do, not what type it is. Python’s dynamic typing enables flexible interfaces.

Why it matters:

  • Decouples code from concrete types
  • Enables polymorphism without inheritance
  • Makes code more reusable and testable
  • Supports composition naturally

Example:

def save_data(database: MySQLDatabase, data: dict):
    database.save(data)

save_data(mysql_db, data)  # OK
save_data(postgres_db, data)  # Type error!

def save_data(database, data):
    """Save data to any database that implements save()."""
    database.save(data)

save_data(mysql_db, data)
save_data(postgres_db, data)
save_data(mock_db, data)  # Easy testing

from typing import Protocol

class Saveable(Protocol):
    def save(self, data: dict) -> None:
        ...

def save_data(database: Saveable, data: dict):
    database.save(data)

Use Type Hints for Clarity

Modern Python embraces optional static typing for better tooling and documentation.

Why it matters:

  • Enables static analysis and IDE support
  • Documents expected types better than comments
  • Catches type errors before runtime
  • Supports gradual typing (add hints incrementally)

Example:

def process_order(order, discount, notify):
    total = calculate_total(order)
    final = apply_discount(total, discount)
    if notify:
        send_email(order)
    return final

from typing import Optional

def process_order(
    order: dict,
    discount: float,
    notify: bool = True
) -> float:
    total = calculate_total(order)
    final = apply_discount(total, discount)
    if notify:
        send_email(order)
    return final

from dataclasses import dataclass

@dataclass
class Order:
    items: list[str]
    total: float

def process_order(
    order: Order,
    discount: float = 0.0,
    notify: bool = True
) -> float:
    final = order.total * (1 - discount)
    if notify:
        send_email(order)
    return final

When to use type hints:

  • Public APIs and interfaces (always)
  • Complex functions with multiple parameters
  • Functions returning Optional or Union types
  • Data structures and domain models

Pythonic Idioms

Comprehensions Over Loops

List, dict, and set comprehensions express transformations concisely and efficiently.

Why it matters:

  • More readable than equivalent loops
  • Often faster (optimized at C level)
  • Clearly expresses intent
  • Naturally produces new collections

Example:

squares = []
for x in range(10):
    squares.append(x ** 2)

active_users = {}
for user in users:
    if user.is_active:
        active_users[user.id] = user

squares = [x ** 2 for x in range(10)]

active_users = {
    user.id: user
    for user in users
    if user.is_active
}

unique_domains = {
    email.split('@')[1]
    for email in email_list
}

total = sum(x ** 2 for x in range(1_000_000))  # No intermediate list

When to use loops instead:

  • Complex logic that doesn’t fit cleanly
  • Multiple operations per iteration
  • Breaking early based on condition
  • Side effects like printing or saving

Context Managers for Resources

Use with statements to manage resources automatically.

Why it matters:

  • Guarantees cleanup even with exceptions
  • More readable than try/finally
  • Prevents resource leaks
  • Handles complex cleanup logic

Example:

def read_config():
    f = open('config.json')
    try:
        data = json.load(f)
        return data
    finally:
        f.close()  # Might forget this

def read_config():
    with open('config.json') as f:
        return json.load(f)

def copy_file(src, dest):
    with open(src, 'rb') as source, open(dest, 'wb') as target:
        target.write(source.read())

from contextlib import contextmanager
import time

@contextmanager
def timer(name):
    start = time.time()
    try:
        yield
    finally:
        print(f"{name} took {time.time() - start:.2f}s")

with timer("Database query"):
    results = database.query("SELECT * FROM users")

F-Strings for String Formatting

F-strings (formatted string literals) provide the clearest string interpolation.

Why it matters:

  • More readable than % formatting or .format()
  • Faster than alternatives
  • Supports expressions directly
  • Type-safe with proper IDE support

Example:

name = "Alice"
age = 30
balance = 1234.5678

msg = "Hello %s, you are %d years old" % (name, age)

msg = "Hello {}, you are {} years old".format(name, age)

msg = f"Hello {name}, you are {age} years old"

msg = f"Balance: ${balance:.2f}"  # Balance: $1234.57
msg = f"In 5 years: {age + 5}"     # In 5 years: 35

report = f"""
User Report:
  Name: {name}
  Age: {age}
  Status: {'Active' if balance > 0 else 'Inactive'}
"""

print(f"{name=}, {age=}")  # name='Alice', age=30

Decorators for Cross-Cutting Concerns

Decorators modify function behavior without changing function code.

Why it matters:

  • Separates concerns cleanly
  • Reusable across functions
  • Preserves original function
  • Enables powerful patterns (caching, logging, validation)

Example:

import functools
import time

def timer(func):
    @functools.wraps(func)  # Preserves func metadata
    def wrapper(*args, **kwargs):
        start = time.time()
        result = func(*args, **kwargs)
        print(f"{func.__name__} took {time.time() - start:.2f}s")
        return result
    return wrapper

@timer
def fetch_data():
    time.sleep(2)
    return {"data": "loaded"}

from functools import lru_cache

@lru_cache(maxsize=128)
def fibonacci(n):
    if n < 2:
        return n
    return fibonacci(n - 1) + fibonacci(n - 2)

def validate_positive(func):
    @functools.wraps(func)
    def wrapper(x):
        if x <= 0:
            raise ValueError("Must be positive")
        return func(x)
    return wrapper

@validate_positive
def calculate_sqrt(x):
    return x ** 0.5

Data Structures

Use Dataclasses for Data Containers

Dataclasses reduce boilerplate for classes that primarily hold data.

Why it matters:

  • Automatic init, repr, eq
  • Less code to maintain
  • Clear intent (this is a data container)
  • Supports type hints naturally

Example:

class User:
    def __init__(self, id, name, email, age):
        self.id = id
        self.name = name
        self.email = email
        self.age = age

    def __repr__(self):
        return f"User(id={self.id}, name={self.name}, ...)"

    def __eq__(self, other):
        if not isinstance(other, User):
            return False
        return (self.id == other.id and
                self.name == other.name and ...)

from dataclasses import dataclass

@dataclass
class User:
    id: int
    name: str
    email: str
    age: int

user = User(1, "Alice", "alice@example.com", 30)
print(user)  # User(id=1, name='Alice', email='alice@example.com', age=30)

from dataclasses import dataclass, field

@dataclass
class Product:
    name: str
    price: float
    quantity: int = 0
    tags: list[str] = field(default_factory=list)  # Avoid mutable default

    def __post_init__(self):
        if self.price < 0:
            raise ValueError("Price cannot be negative")

Prefer Named Tuples for Immutable Data

Named tuples provide lightweight immutable containers with named fields.

Why it matters:

  • Immutable by default (thread-safe)
  • Less memory than classes
  • Named fields improve readability
  • Backwards compatible with regular tuples

Example:

from typing import NamedTuple

class Point(NamedTuple):
    x: float
    y: float

    def distance(self):
        return (self.x ** 2 + self.y ** 2) ** 0.5

p = Point(3.0, 4.0)
print(p.x, p.y)  # Named access
print(p[0], p[1])  # Also supports indexing
print(p.distance())  # 5.0

x, y = p

When to use dataclass vs namedtuple:

  • Dataclass: Mutable data, methods, inheritance
  • NamedTuple: Immutable data, simple structure

Virtual Environments and Dependencies

Always Use Virtual Environments

Isolate project dependencies to avoid conflicts and ensure reproducibility.

Why it matters:

  • Prevents version conflicts between projects
  • Makes dependencies explicit and portable
  • Matches production environment
  • Enables reproducible builds

Example:

python -m venv venv

source venv/bin/activate

venv\Scripts\activate

pip install -r requirements.txt

pip freeze > requirements.txt

Pin Dependencies with Lock Files

Lock files ensure reproducible installations across environments.

Example:

requests>=2.28.0,<3.0.0
flask>=2.3.0

requests==2.31.0
certifi==2023.7.22
charset-normalizer==3.2.0
flask==2.3.3

-r requirements.txt
pytest>=7.4.0
black>=23.0.0
mypy>=1.5.0

Code Organization

Keep Functions Small and Focused

Each function should do one thing well.

Why it matters:

  • Easier to test in isolation
  • Easier to understand and debug
  • Promotes reuse
  • Follows Single Responsibility Principle

Example:

def process_order(order_data):
    # Validate
    if not order_data.get('items'):
        raise ValueError("Empty order")

    # Calculate total
    total = 0
    for item in order_data['items']:
        total += item['price'] * item['quantity']

    # Apply discount
    if order_data.get('discount_code'):
        discount = lookup_discount(order_data['discount_code'])
        total *= (1 - discount)

    # Save to database
    db.save_order({
        'items': order_data['items'],
        'total': total,
        'customer': order_data['customer']
    })

    # Send email
    send_email(
        to=order_data['customer']['email'],
        subject="Order Confirmation",
        body=f"Total: ${total:.2f}"
    )

def validate_order(order_data):
    if not order_data.get('items'):
        raise ValueError("Empty order")

def calculate_total(items):
    return sum(
        item['price'] * item['quantity']
        for item in items
    )

def apply_discount(total, discount_code):
    if not discount_code:
        return total
    discount = lookup_discount(discount_code)
    return total * (1 - discount)

def save_order(order_data, total):
    db.save_order({
        'items': order_data['items'],
        'total': total,
        'customer': order_data['customer']
    })

def send_confirmation(email, total):
    send_email(
        to=email,
        subject="Order Confirmation",
        body=f"Total: ${total:.2f}"
    )

def process_order(order_data):
    validate_order(order_data)
    total = calculate_total(order_data['items'])
    total = apply_discount(total, order_data.get('discount_code'))
    save_order(order_data, total)
    send_confirmation(order_data['customer']['email'], total)

Use Properties for Computed Attributes

Properties provide attribute syntax for computed values.

Why it matters:

  • Clean attribute access syntax
  • Can add validation without API changes
  • Lazy computation
  • Backwards compatible with attributes

Example:

class Rectangle:
    def __init__(self, width, height):
        self.width = width
        self.height = height

    def get_area(self):
        return self.width * self.height

    def set_width(self, value):
        if value <= 0:
            raise ValueError("Width must be positive")
        self.width = value

rect = Rectangle(10, 5)
print(rect.get_area())  # Method call syntax
rect.set_width(20)

class Rectangle:
    def __init__(self, width, height):
        self._width = width
        self._height = height

    @property
    def width(self):
        return self._width

    @width.setter
    def width(self, value):
        if value <= 0:
            raise ValueError("Width must be positive")
        self._width = value

    @property
    def area(self):
        return self._width * self._height

rect = Rectangle(10, 5)
print(rect.area)  # Computed property, attribute syntax
rect.width = 20   # Validated assignment

Design Philosophy

The Zen of Python

Run import this in Python to see the guiding principles:

  • Beautiful is better than ugly - Favor readable, elegant solutions
  • Explicit is better than implicit - Make behavior clear, not magical
  • Simple is better than complex - Choose simple solutions over complex ones
  • Flat is better than nested - Avoid deep nesting
  • Readability counts - Code is read more than written
  • Special cases aren’t special enough to break the rules - Follow conventions consistently
  • Errors should never pass silently - Don’t swallow exceptions
  • There should be one obvious way to do it - Prefer the Pythonic way

When to Break the Rules

Best practices are guidelines, not absolute laws. Break them when:

  • Performance critical sections: Profiling shows a practice hurts performance
  • Compatibility requirements: Need to match existing API or external system
  • Library constraints: Third-party library requires specific pattern
  • Team consensus: Team agrees on different approach with clear reasoning

Always document deviations with clear explanations.

Summary

Pythonic code emerges from embracing the language’s philosophy rather than importing patterns from other ecosystems. The EAFP principle focuses code on the happy path while handling exceptional cases gracefully through Python’s exception system. Duck typing and protocols enable flexible interfaces that accept any object with the right behavior rather than the right inheritance hierarchy.

Type hints bridge the gap between Python’s dynamic nature and the benefits of static analysis. They document expectations clearly while preserving the flexibility to gradually add types as code evolves. Modern Python development includes type hints not as a burden but as a tool for better IDE support and clearer interfaces.

Comprehensions, f-strings, context managers, and decorators represent Python’s approach to common programming patterns. List comprehensions express transformations more clearly than equivalent loops. F-strings make string interpolation readable at a glance. Context managers ensure resources clean up reliably. Decorators separate cross-cutting concerns from core logic. These idioms don’t just save keystrokes - they communicate intent more directly.

Dataclasses and named tuples reduce boilerplate for common data structures. Choose dataclasses when you need mutability and methods, named tuples for immutable data with simple structure. Both integrate naturally with type hints to create self-documenting data models.

Virtual environments and dependency management might seem like operational concerns, but they’re fundamental to writing Python that works reliably across different environments. Isolate dependencies, pin versions precisely, and separate development from production requirements. This discipline prevents the “works on my machine” problem.

Small, focused functions compose into larger behaviors while remaining individually testable and comprehensible. Properties provide the clean syntax of attributes with the power to validate and compute values. These patterns make code easier to work with whether you’re adding features, fixing bugs, or bringing new developers onto the project.

The Zen of Python isn’t just philosophical - it provides practical guidance for everyday decisions. When you face a choice between clever and clear, choose clear. When you can make behavior explicit rather than implicit, be explicit. When simple and complex solutions both work, prefer simple. These principles compound over time into codebases that remain maintainable as they grow.

Related Content

Last updated