Code is read far more often than it's written. Here's how to make your Python code maintainable.

Naming Things Well

Names should reveal intent:

# Bad
d = 86400
lst = get_data()
def proc(x):
    pass
 
# Good
SECONDS_PER_DAY = 86400
active_users = get_active_users()
def process_payment(transaction):
    pass

Guidelines:

  • Variables: nouns that describe what they hold
  • Functions: verbs that describe what they do
  • Booleans: is_, has_, can_ prefixes
  • Constants: UPPER_SNAKE_CASE

Function Design

Functions should do one thing:

# Bad - does too much
def handle_user(user_data):
    validated = validate(user_data)
    user = create_user(validated)
    send_welcome_email(user)
    log_signup(user)
    return user
 
# Better - single responsibility
def create_user(user_data):
    validated = validate(user_data)
    return User(**validated)
 
# Called separately
user = create_user(data)
send_welcome_email(user)
log_signup(user)

Keep functions short. If you need comments to explain sections, those sections might be separate functions.

Default Arguments

Use sensible defaults:

def fetch_users(
    limit: int = 100,
    include_inactive: bool = False,
    sort_by: str = "created_at"
) -> list[User]:
    pass
 
# Caller only specifies what differs
users = fetch_users(limit=50)

Never use mutable defaults:

# Wrong - shared list across calls
def add_item(item, items=[]):
    items.append(item)
    return items
 
# Right
def add_item(item, items=None):
    if items is None:
        items = []
    items.append(item)
    return items

Early Returns

Reduce nesting with guard clauses:

# Hard to follow
def process_order(order):
    if order:
        if order.is_valid:
            if order.items:
                # actual logic buried here
                return calculate_total(order)
            else:
                return 0
        else:
            raise InvalidOrder()
    else:
        raise OrderNotFound()
 
# Clearer with early returns
def process_order(order):
    if not order:
        raise OrderNotFound()
    if not order.is_valid:
        raise InvalidOrder()
    if not order.items:
        return 0
    
    return calculate_total(order)

Meaningful Structure

Group related code:

class OrderProcessor:
    # Public interface first
    def process(self, order: Order) -> Receipt:
        self._validate(order)
        total = self._calculate_total(order)
        return self._create_receipt(order, total)
    
    # Private helpers below
    def _validate(self, order: Order) -> None:
        ...
    
    def _calculate_total(self, order: Order) -> Decimal:
        ...
    
    def _create_receipt(self, order: Order, total: Decimal) -> Receipt:
        ...

Comments That Add Value

Don't explain what—explain why:

# Bad - restates the code
# Increment counter by 1
counter += 1
 
# Good - explains the why
# Rate limit: max 100 requests per minute per user
if request_count > 100:
    raise RateLimitExceeded()
 
# Good - documents non-obvious behavior
# Sleep briefly to avoid hammering the API during retry
time.sleep(0.1 * attempt)

Use Type Hints

Types serve as documentation and catch bugs:

from typing import Optional
from datetime import datetime
 
def find_user(
    user_id: int,
    include_deleted: bool = False
) -> Optional[User]:
    """Find a user by ID.
    
    Returns None if not found.
    """
    ...

Error Handling

Be specific about exceptions:

# Bad - catches everything
try:
    result = risky_operation()
except:
    pass
 
# Better - specific exceptions
try:
    result = fetch_from_api()
except requests.Timeout:
    logger.warning("API timeout, using cached data")
    result = get_cached_data()
except requests.HTTPError as e:
    logger.error(f"API error: {e}")
    raise

Configuration

Separate config from code:

# config.py
from pydantic import BaseSettings
 
class Settings(BaseSettings):
    database_url: str
    api_key: str
    debug: bool = False
    
    class Config:
        env_file = ".env"
 
settings = Settings()
 
# usage.py
from config import settings
 
db = connect(settings.database_url)

Testing Considerations

Write code that's easy to test:

# Hard to test - hidden dependency
def send_notification(user_id):
    user = database.get_user(user_id)  # Global database
    email.send(user.email)  # Global email service
 
# Easy to test - dependencies injected
def send_notification(user: User, email_service: EmailService):
    email_service.send(user.email)

Module Organization

Keep files focused:

myapp/
├── __init__.py
├── models.py      # Data structures
├── services.py    # Business logic
├── api.py         # HTTP handlers
├── database.py    # Data access
└── utils.py       # Shared helpers

When a file grows too large, split by feature:

myapp/
├── users/
│   ├── models.py
│   ├── services.py
│   └── api.py
├── orders/
│   ├── models.py
│   ├── services.py
│   └── api.py
└── shared/
    └── utils.py

The Readability Test

Ask yourself:

  • Can someone new understand this in 5 minutes?
  • Will I understand this in 6 months?
  • Is the purpose clear without running the code?

If not, refactor until it is. Your future self will thank you.

React to this post: