PEP 8 — The Python Style Guide

Naming
variable_name = 1              # snake_case for variables and functions
CONSTANT_NAME = 3.14           # SCREAMING_SNAKE for constants
ClassName = "example"          # PascalCase for classes
_private = "internal use"      # leading _ for "private"
__mangled = "name mangled"     # double __ for name mangling
Spacing
x = 1 + 2           # spaces around operators
x = [1, 2, 3]       # space after comma
def f(a, b=0):      # no space around default arg
f(a=1, b=2)         # no space around keyword arg
Imports
# 1. Standard library
import os
import json
from pathlib import Path

# 2. Third-party
import numpy as np
import pandas as pd

# 3. Local
from mymodule import my_function
Line Length
# PEP 8: 79 chars. In practice: 88-100 is common (Black uses 88)
result = (very_long_variable_name
          + another_long_variable_name
          + yet_another_one)

Clean Code Principles

Functions

Functions should do one thing. Keep parameters to ~3. Use descriptive verb+noun names for booleans and getters.

❌ Multiple responsibilities
def process_order(order):
    validate(order)
    calculate_price(order)
    send_email(order)
    update_database(order)
✅ Composed single-purpose functions
def process_order(order):
    validated = validate_order(order)
    priced = calculate_order_price(validated)
    saved = save_order(priced)
    notify_customer(saved)
    return saved
❌ Too many params
def create_user(name, email, age,
    city, phone, role, ...):
    ...
✅ Limited params
def create_user(name: str,
    email: str) -> User:
    ...
def is_valid_email(email: str) -> bool: ...   # ✅ verb + noun for bool
def get_user_by_id(id: int) -> User: ...      # ✅ verb + noun
def d(x): ...                                 # ❌ meaningless name

Variables

❌ Mysterious abbreviations
d = datetime.today()
ttl = len(students) * avg_s
✅ Clear names
today = datetime.today()
total_score = (len(students)
    * average_score_per_student)
# ✅ Use constants for magic numbers
MAX_RETRIES = 3
TIMEOUT_SECONDS = 30
DEFAULT_PAGE_SIZE = 100

if retries > MAX_RETRIES:  # clear intent
    raise TimeoutError()

Error Handling

❌ Bare except
try:
    result = risky_op()
except:
    pass  # swallows ALL exceptions!
✅ Specific exceptions
try:
    result = risky_op()
except ValueError as e:
    logger.warning(f"Invalid: {e}")
    result = default_value
except ConnectionError as e:
    logger.error(f"Failed: {e}")
    raise  # re-raise if can't handle
# ✅ Context managers for cleanup
with open("file.txt") as f:
    data = f.read()
# file is closed even if an exception occurs

Python Idioms

# --- UNPACKING ---
a, b, c = [1, 2, 3]
first, *rest = [1, 2, 3, 4, 5]

# --- ENUMERATE INSTEAD OF RANGE(LEN()) ---
for i, item in enumerate(items):  # ✅
for i in range(len(items)):       # ❌ un-Pythonic

# --- ZIP FOR PARALLEL ITERATION ---
for name, score in zip(names, scores):   # ✅
for i in range(len(names)):              # ❌
    name, score = names[i], scores[i]

# --- TRUTHY/FALSY ---
if user_list:          # ✅
if len(user_list) > 0: # ❌ unnecessary

if value is None:  # ✅ for None check (identity)
if value == None:  # ❌

# --- DICT GET WITH DEFAULT ---
value = d.get("key", default)  # ✅
value = d["key"] if "key" in d else default  # ❌

# --- STRING JOINING ---
result = ", ".join(words)      # ✅ efficient
result = ""
for w in words:
    result += w + ", "         # ❌ O(n²)

# --- COMPREHENSIONS OVER MAP/FILTER ---
squares = [x**2 for x in nums]             # ✅
squares = list(map(lambda x: x**2, nums))  # ❌ less readable

Type Hints Best Practices

from typing import Optional, Union
from collections.abc import Sequence, Mapping

def process_data(
    items: list[str],           # Python 3.9+ built-in generics
    config: dict[str, int],
    callback: Optional[callable] = None,
) -> tuple[list[str], int]:
    ...

# For older Python
from typing import List, Dict, Tuple, Optional
def old_style(items: List[str]) -> Optional[str]: ...

# Protocol for duck typing
from typing import Protocol
class Drawable(Protocol):
    def draw(self) -> None: ...

Logging

import logging

# ✅ Use logging, not print
logging.basicConfig(
    level=logging.INFO,
    format="%(asctime)s — %(name)s — %(levelname)s — %(message)s"
)
logger = logging.getLogger(__name__)

logger.debug("Debug info")             # development details
logger.info("Server started")          # normal operations
logger.warning("Low memory")           # unexpected but OK
logger.error("DB connection failed")   # problem
logger.critical("System down")         # fatal

Testing

def add(a: int, b: int) -> int:
    return a + b

# test_math.py
def test_add():
    assert add(1, 2) == 3
    assert add(-1, 1) == 0
    assert add(0, 0) == 0

def test_add_type_error():
    with pytest.raises(TypeError):
        add("1", 2)

# Parametrised test
import pytest
@pytest.mark.parametrize("a,b,expected", [
    (1, 2, 3),
    (0, 0, 0),
    (-1, 1, 0),
])
def test_add_parametrised(a, b, expected):
    assert add(a, b) == expected

Why Does Each Rule Exist?

Naming Conventions

# ✅ snake_case for variables: why?
# Easier to read than camelCase for long names
# Python standard since PEP 8 (ecosystem consistency)
variable_name = 1
myVariableName = 1  # feels "un-Pythonic"

# ✅ SCREAMING_SNAKE for constants: why?
# ALL CAPS catches attention: "this is fixed, don't change it"
# Easy to grep for constant definitions
MAX_RETRIES = 3

# ✅ PascalCase for classes: why?
# Visually distinct from variables
# Differentiates: MyClass() vs my_function()
class MyClass:
    pass

Spacing & Line Length

# ✅ Spaces around operators: why?
x = 1 + 2  # readable: clear precedence
x=1+2      # hard to parse visually

# ✅ 79-char line limit (PEP 8): why?
# 1. Old terminals were 80 chars
# 2. Side-by-side diffs: 80 + 80 = 160
# 3. Forces you to break complex lines
# Black uses 88 characters (common compromise)

Import Order

# ✅ Standard library → Third-party → Local: why?
# Readers immediately see external dependencies
# Easy to audit (all third-party together)
# Standard library API is stable, safe to import first

import os                    # Standard library
from pathlib import Path

import numpy as np           # Third-party
import pandas as pd

from mymodule import func    # Local

Before / After Refactoring

Example 1 — Naming Clarity

❌ Before
def p(t, r):
    return t * (1 - r)

x = 100
y = p(x, 0.1)
✅ After
def calculate_discounted_price(
    original_price: float,
    discount_rate: float
) -> float:
    """Calculate price after discount."""
    return original_price * (1 - discount_rate)

original_amount = 100
discounted = calculate_discounted_price(
    original_amount, 0.1
)

Example 2 — Line Length & Readability

❌ Before
users = [u for u in users
  if u.location == "London"
  and u.age > 30
  and u.subscription == "premium"]
✅ After
def is_premium_london_user(user):
    return (
        user.location == "London"
        and user.age > 30
        and user.subscription == "premium"
    )

premium_users = [
    u for u in users
    if is_premium_london_user(u)
]

Example 3 — Error Handling

❌ Before
try:
    result = risky_operation()
except:
    pass
✅ After
try:
    result = risky_operation()
except ValueError as e:
    logger.warning(f"Invalid: {e}")
    result = default_value
except ConnectionError as e:
    logger.error(f"Failed: {e}")
    raise

Tools: black, flake8, mypy, autopep8

pip install black flake8 mypy autopep8

black (Auto-formatter)

# Format a file
black myfile.py

# Format entire directory
black .

# Check without modifying
black --check myfile.py

# Custom line length (default 88)
black --line-length 100 myfile.py

flake8 (Linter)

# Check file
flake8 myfile.py

# Ignore certain rules
flake8 --ignore=E501,W503 .

# Example output:
# myfile.py:1:1: F401 'os' imported but unused
# myfile.py:5:1: E302 expected 2 blank lines, found 1
# myfile.py:10:80: E501 line too long (85 > 79)

mypy (Type Checker)

# Check file
mypy myfile.py

# Strict mode (recommended)
mypy --strict myfile.py

# Example error:
# myfile.py:4: Argument 1 to "greet" has
#   incompatible type "int"; expected "str"

Pre-Commit Hooks

# Install pre-commit
pip install pre-commit

# .pre-commit-config.yaml
repos:
  - repo: https://github.com/psf/black
    rev: 23.12.1
    hooks:
      - id: black
  - repo: https://github.com/PyCQA/flake8
    rev: 6.1.0
    hooks:
      - id: flake8
  - repo: https://github.com/pre-commit/mirrors-mypy
    rev: v1.7.1
    hooks:
      - id: mypy

# Install hooks
pre-commit install
# Now on every `git commit`, hooks run automatically!