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
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!