TechLead
Lesson 25 of 25
5 min read
Python

Python Best Practices

Learn Python coding standards, project structure, linting, formatting, and professional development practices

PEP 8 and Code Style

PEP 8 is the official Python style guide. Following it ensures your code is consistent and readable. Modern Python development uses automated tools like Ruff (linting and formatting), Black (formatting), and isort (import sorting) to enforce style automatically.

# PEP 8 Naming Conventions
my_variable = "snake_case for variables"
MY_CONSTANT = "UPPER_CASE for constants"

def my_function():
    """snake_case for functions."""
    pass

class MyClass:
    """PascalCase for classes."""
    
    def my_method(self):
        """snake_case for methods."""
        pass

_private_var = "leading underscore for private"
__name_mangled = "double underscore for name mangling"

# Good formatting
def calculate_total(
    items: list[dict],
    tax_rate: float = 0.08,
    discount: float = 0.0,
) -> float:
    """
    Calculate the total price including tax and discount.
    
    Args:
        items: List of items with 'price' and 'quantity' keys.
        tax_rate: Tax rate as a decimal (default 8%).
        discount: Discount as a decimal (default 0%).
    
    Returns:
        The total price after tax and discount.
    """
    subtotal = sum(item["price"] * item["quantity"] for item in items)
    tax = subtotal * tax_rate
    total = (subtotal + tax) * (1 - discount)
    return round(total, 2)

Project Structure

# Recommended project layout
# myproject/
#   pyproject.toml        # Project config, dependencies, tool settings
#   README.md
#   LICENSE
#   .gitignore
#   .env                  # Environment variables (NOT in git!)
#   .env.example          # Template for .env
#   src/
#     myproject/
#       __init__.py
#       main.py           # Entry point
#       config.py          # Configuration management
#       models/
#         __init__.py
#         user.py
#         product.py
#       services/
#         __init__.py
#         user_service.py
#         email_service.py
#       api/
#         __init__.py
#         routes/
#           __init__.py
#           users.py
#           products.py
#         middleware.py
#         dependencies.py
#       utils/
#         __init__.py
#         helpers.py
#         validators.py
#   tests/
#     __init__.py
#     conftest.py          # Shared fixtures
#     test_user_service.py
#     test_api_users.py
#   scripts/
#     seed_data.py
#     migrate.py

Configuration Management

# config.py - using pydantic-settings
from pydantic_settings import BaseSettings
from functools import lru_cache

class Settings(BaseSettings):
    """Application settings loaded from environment variables."""
    
    app_name: str = "My Application"
    debug: bool = False
    database_url: str = "sqlite:///./app.db"
    secret_key: str
    api_key: str
    redis_url: str = "redis://localhost:6379"
    
    # Nested settings
    cors_origins: list[str] = ["http://localhost:3000"]
    max_upload_size: int = 10_000_000  # 10MB
    
    model_config = {
        "env_file": ".env",
        "env_file_encoding": "utf-8",
    }

@lru_cache
def get_settings() -> Settings:
    return Settings()

# Usage
settings = get_settings()
print(settings.database_url)
print(settings.debug)

# .env file
# DATABASE_URL=postgresql://user:pass@localhost/mydb
# SECRET_KEY=your-secret-key-here
# API_KEY=your-api-key
# DEBUG=true

Linting and Formatting with Ruff

# pip install ruff

# Lint your code
# ruff check .

# Auto-fix issues
# ruff check --fix .

# Format code (like Black)
# ruff format .

# pyproject.toml configuration
# [tool.ruff]
# target-version = "py312"
# line-length = 100
#
# [tool.ruff.lint]
# select = [
#     "E",    # pycodestyle errors
#     "W",    # pycodestyle warnings
#     "F",    # pyflakes
#     "I",    # isort
#     "N",    # pep8-naming
#     "UP",   # pyupgrade
#     "B",    # flake8-bugbear
#     "SIM",  # flake8-simplify
#     "S",    # flake8-bandit (security)
# ]
# ignore = ["E501"]  # Ignore line length (handled by formatter)
#
# [tool.ruff.lint.per-file-ignores]
# "tests/**/*.py" = ["S101"]  # Allow assert in tests

Logging

import logging

# Configure logging
logging.basicConfig(
    level=logging.INFO,
    format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
    datefmt="%Y-%m-%d %H:%M:%S",
)

# Create a logger
logger = logging.getLogger(__name__)

# Use appropriate levels
logger.debug("Detailed debugging info")
logger.info("General information")
logger.warning("Something unexpected happened")
logger.error("An error occurred")
logger.critical("System is in a critical state")

# Structured logging with extra data
logger.info("User created", extra={"user_id": 42, "email": "alice@example.com"})

# Exception logging
try:
    result = 10 / 0
except ZeroDivisionError:
    logger.exception("Failed to calculate result")
    # This logs the full traceback automatically

# Production logging config
import logging.config

LOGGING_CONFIG = {
    "version": 1,
    "disable_existing_loggers": False,
    "formatters": {
        "default": {
            "format": "%(asctime)s [%(levelname)s] %(name)s: %(message)s",
        },
        "json": {
            "class": "pythonjsonlogger.jsonlogger.JsonFormatter",
            "format": "%(asctime)s %(levelname)s %(name)s %(message)s",
        },
    },
    "handlers": {
        "console": {
            "class": "logging.StreamHandler",
            "formatter": "default",
        },
        "file": {
            "class": "logging.handlers.RotatingFileHandler",
            "filename": "app.log",
            "maxBytes": 10_000_000,
            "backupCount": 5,
            "formatter": "default",
        },
    },
    "root": {
        "level": "INFO",
        "handlers": ["console", "file"],
    },
}

logging.config.dictConfig(LOGGING_CONFIG)

Python Anti-Patterns to Avoid

# BAD: Mutable default argument
def bad(items=[]):
    items.append(1)
    return items

# GOOD:
def good(items=None):
    if items is None:
        items = []
    items.append(1)
    return items

# BAD: Bare except
try:
    risky()
except:  # Catches SystemExit, KeyboardInterrupt too!
    pass

# GOOD: Specific exception
try:
    risky()
except ValueError as e:
    logger.error(f"Validation error: {e}")

# BAD: Checking type with ==
if type(x) == int:
    pass

# GOOD: isinstance
if isinstance(x, int):
    pass

# BAD: Manual resource management
f = open("file.txt")
data = f.read()
f.close()  # Might not run if exception occurs!

# GOOD: Context manager
with open("file.txt") as f:
    data = f.read()

# BAD: Using global variables
counter = 0
def increment():
    global counter
    counter += 1

# GOOD: Use a class or pass as argument
class Counter:
    def __init__(self):
        self.value = 0
    def increment(self):
        self.value += 1

# BAD: String concatenation in a loop
result = ""
for word in words:
    result += word + " "

# GOOD: Use join
result = " ".join(words)

Key Takeaways

  • Use Ruff: Modern, fast linter and formatter that replaces multiple tools
  • Follow PEP 8: Consistent style makes code readable and maintainable
  • Proper logging: Use the logging module, not print(), in production
  • Environment variables: Use pydantic-settings for type-safe configuration
  • Avoid anti-patterns: Learn common pitfalls and their Pythonic solutions

Continue Learning