Readable Python is easier to review, test, and change. Here are practical habits for naming, type hints, small functions, and error handling.

Writing clean Python isn't about following rules for the sake of rules. It's about writing code that your future self — and your teammates — will actually enjoy working with.

Here are the practices that have made the biggest difference in my code quality over the years.

Naming: The Foundation of Readability

Good naming is the single most impactful thing you can do for code readability. If your code needs comments to explain what it does (not why), the naming probably needs work.

# ❌ What does this do?
def proc(d, f):
    r = []
    for i in d:
        if f(i):
            r.append(i)
    return r

# ✅ Self-documenting
def filter_items(items: list, predicate: callable) -> list:
    return [item for item in items if predicate(item)]

Naming Conventions That Matter

  • Functions and variables → snake_case — calculate_total_price
  • Classes → PascalCase — OrderProcessor
  • Constants → UPPER_SNAKE_CASE — MAX_RETRY_COUNT
  • Private attributes → Leading underscore — _internal_cache
  • Boolean variables → Use prefixes — is_active, has_permission, can_edit

"Code is read far more often than it is written. Optimize for the reader, not the writer."

Type Hints: Your Code's Built-in Documentation

Type hints don't just help your IDE — they communicate intent. They tell the next developer exactly what a function expects and returns, without them having to read the implementation.

from typing import Optional
from datetime import datetime
from decimal import Decimal

def calculate_invoice_total(
    items: list[dict[str, Decimal]],
    tax_rate: Decimal = Decimal("0.0"),
    discount: Optional[Decimal] = None,
    applied_at: Optional[datetime] = None,
) -> dict[str, Decimal]:
    """Calculate the final invoice total with tax and discount.

    Args:
        items: List of items, each with 'price' and 'quantity' keys.
        tax_rate: Tax rate as a decimal (e.g., 0.08 for 8%).
        discount: Optional flat discount amount.
        applied_at: When the calculation was performed.

    Returns:
        Dictionary with 'subtotal', 'tax', 'discount', and 'total'.
    """
    subtotal = sum(
        item['price'] * item['quantity'] for item in items
    )
    tax_amount = subtotal * tax_rate
    discount_amount = discount or Decimal("0")
    total = subtotal + tax_amount - discount_amount

    return {
        'subtotal': subtotal,
        'tax': tax_amount,
        'discount': discount_amount,
        'total': max(total, Decimal("0")),
    }

The Single Responsibility Principle

Every function should do one thing. If you find yourself using the word "and" to describe what a function does — it's doing too much.

# ❌ This function does too many things
def process_order(order_data):
    # Validate input
    if not order_data.get('email'):
        raise ValueError("Email required")
    if not order_data.get('items'):
        raise ValueError("Items required")

    # Calculate totals
    subtotal = sum(i['price'] * i['qty'] for i in order_data['items'])
    tax = subtotal * Decimal('0.08')
    total = subtotal + tax

    # Save to database
    order = Order.objects.create(
        email=order_data['email'],
        total=total,
    )

    # Send confirmation email
    send_mail('Order Confirmed', f'Total: ${total}', ...)

    return order
# ✅ Each function has a single, clear purpose
def validate_order_data(data: dict) -> None:
    required = ['email', 'items']
    for field in required:
        if not data.get(field):
            raise ValueError(f"{field} is required")

def calculate_order_total(items: list) -> dict:
    subtotal = sum(i['price'] * i['qty'] for i in items)
    tax = subtotal * Decimal('0.08')
    return {'subtotal': subtotal, 'tax': tax, 'total': subtotal + tax}

def create_order(email: str, totals: dict) -> Order:
    return Order.objects.create(email=email, **totals)

def send_order_confirmation(order: Order) -> None:
    send_mail('Order Confirmed', f'Total: ${order.total}', ...)

Now each function is testable, reusable, and easy to understand in isolation.

Context Managers for Resource Safety

Whenever you're working with resources that need cleanup — files, database connections, locks, temporary state — use context managers:

from contextlib import contextmanager
import time

@contextmanager
def timer(label: str):
    """Measure and print execution time of a code block."""
    start = time.perf_counter()
    try:
        yield
    finally:
        elapsed = time.perf_counter() - start
        print(f"{label}: {elapsed:.3f}s")

# Usage
with timer("Database query"):
    results = heavy_database_query()

with timer("API call"):
    response = call_external_api()

List Comprehensions: Know When to Use Them

List comprehensions are one of Python's most elegant features. But there's a line between elegant and unreadable:

# ✅ Clean and Pythonic
active_emails = [user.email for user in users if user.is_active]

# ✅ Still readable with a simple condition
adults = [p for p in people if p.age >= 18 and p.has_consent]

# ❌ Too complex — use a regular loop instead
results = [
    transform(item.value)
    for group in data
    for item in group.items
    if item.is_valid and item.status != 'archived'
    and item.category in allowed_categories
]

# ✅ Better as a regular loop with a helper function
def get_valid_results(data):
    results = []
    for group in data:
        for item in group.items:
            if is_processable(item):
                results.append(transform(item.value))
    return results

My rule: If a comprehension doesn't fit on one or two lines and remain immediately obvious — use a loop.

Error Handling That Actually Helps

Good error handling tells you what went wrong and how to fix it, not just that something failed:

# ❌ Catches everything, tells you nothing
try:
    process_payment(order)
except Exception:
    print("Something went wrong")

# ✅ Specific, informative, actionable
try:
    process_payment(order)
except PaymentGatewayError as e:
    logger.error(f"Payment failed for order #{order.id}: {e}")
    notify_support(order, error=e)
    raise UserFacingError(
        "Payment couldn't be processed. Please try again "
        "or use a different payment method."
    ) from e
except InsufficientFundsError:
    raise UserFacingError(
        "Insufficient funds. Please check your balance."
    )

Dataclasses: Stop Writing Boilerplate

If you're still writing __init__, __repr__, and __eq__ methods by hand — dataclasses will feel like magic:

from dataclasses import dataclass, field
from datetime import datetime

@dataclass
class BlogPost:
    title: str
    slug: str
    content: str
    author: str
    tags: list[str] = field(default_factory=list)
    is_published: bool = False
    created_at: datetime = field(default_factory=datetime.now)

    @property
    def reading_time(self) -> int:
        words = len(self.content.split())
        return max(1, words // 200)

    def publish(self) -> None:
        self.is_published = True

# Clean, readable instantiation
post = BlogPost(
    title="Clean Code in Python",
    slug="clean-code-python",
    content="...",
    author="Samiullah",
    tags=["python", "clean-code"],
)

Project Structure That Scales

How you organize your code matters just as much as how you write it:

project/
├── src/
│   ├── core/              # Shared utilities, base classes
│   │   ├── exceptions.py
│   │   ├── validators.py
│   │   └── utils.py
│   ├── services/          # Business logic layer
│   │   ├── payment.py
│   │   ├── notification.py
│   │   └── analytics.py
│   ├── models/            # Data models
│   ├── api/               # API endpoints
│   └── config/            # Settings, environment
├── tests/
│   ├── unit/
│   ├── integration/
│   └── conftest.py
├── pyproject.toml
└── README.md

Keep your business logic in services — not in views, not in models. This makes your code testable and framework-agnostic.

The Bottom Line

Clean code isn't about perfection. It's about empathy — for your future self, for your teammates, for anyone who has to maintain what you've built.

Start with naming. Add type hints. Break large functions into smaller ones. Handle errors thoughtfully. These aren't revolutionary ideas — but consistently applying them is what separates good code from great code.

You don't have to adopt everything at once. Pick one practice, apply it to your next pull request, and build from there. Your codebase will thank you.

Where to start

Pick the part of this guide that addresses your current problem. Make one change, check the result, and decide what to do next. If you need help applying it to a Django website or app, tell me about your project.