Skip to content
Home / Skills / Python / Best Practices
PY

Best Practices

Python fundamentals v1.0.0

Python Best Practices

Modern Python development requires adherence to established conventions and tooling to ensure code quality, maintainability, and team collaboration. This skill covers PEP 8 style guidelines, type hints for static analysis, comprehensive documentation strategies, project structure patterns, and dependency isolation through virtual environments. Following these practices reduces bugs, improves code readability, and enables effective collaboration across teams.

Key Concepts

  • PEP 8 Compliance - Official Python style guide covering naming conventions, indentation, line length, imports, and whitespace
  • Type Hints (PEP 484) - Static type annotations enabling IDE support, mypy validation, and self-documenting code
  • Docstrings (PEP 257) - Structured documentation using Google, NumPy, or Sphinx formats
  • Project Structure - Standardized layout with src/ layout, tests/, docs/, and configuration files
  • Virtual Environments - Isolated Python environments using venv, virtualenv, or Poetry for dependency management
  • Dependency Management - requirements.txt, pyproject.toml, Poetry lock files, and reproducible builds
  • Code Quality Tools - Black (formatting), isort (import sorting), flake8/ruff (linting), mypy (type checking)
  • Import Organization - Grouping standard library, third-party, and local imports with consistent ordering

Best Practices

  1. Always use type hints - Annotate function signatures, class attributes, and complex variables for static analysis
  2. Format with Black - Use Black code formatter with default settings for consistent, zero-configuration formatting
  3. Organize imports with isort - Group and sort imports: standard library, third-party, local, with one blank line between groups
  4. Write comprehensive docstrings - Document all public modules, classes, functions with parameters, return values, and examples
  5. Use src/ layout - Place package code in src/package_name/ to avoid import confusion and enable proper testing
  6. Pin dependencies - Use lock files (Poetry.lock, requirements.txt with versions) for reproducible environments
  7. Validate with mypy - Run mypy with strict mode to catch type errors before runtime
  8. Follow naming conventions - snake_case for functions/variables, PascalCase for classes, UPPER_CASE for constants
  9. Limit line length - Keep lines under 88 characters (Black default) or 79 (PEP 8 strict)
  10. Separate concerns - Split large modules, use packages for organization, maintain single responsibility per module

Code Examples

Type Hints and Function Signatures

# ✅ GOOD: Comprehensive type hints with modern syntax
from typing import TypeAlias
from collections.abc import Sequence, Mapping

UserId: TypeAlias = int
UserData: TypeAlias = Mapping[str, str | int]

def process_users(
    user_ids: Sequence[UserId],
    metadata: UserData | None = None,
    *,
    strict: bool = True
) -> list[dict[str, UserId | str]]:
    """
    Process user IDs with optional metadata.
    
    Args:
        user_ids: Sequence of user identifiers to process
        metadata: Optional mapping of user attributes
        strict: Enforce strict validation (keyword-only)
        
    Returns:
        List of processed user records with IDs and status
        
    Raises:
        ValueError: If user_ids is empty and strict=True
    """
    if strict and not user_ids:
        raise ValueError("user_ids cannot be empty in strict mode")
    
    return [{"id": uid, "status": "processed"} for uid in user_ids]

# ❌ BAD: Missing type hints and documentation
def process_users(user_ids, metadata=None, strict=True):
    if strict and not user_ids:
        raise ValueError("user_ids cannot be empty")
    return [{"id": uid, "status": "processed"} for uid in user_ids]

Project Structure and Import Organization

# ✅ GOOD: Proper import grouping with isort
# Standard library imports
import asyncio
import logging
from pathlib import Path
from typing import Final

# Third-party imports
import httpx
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel, Field

# Local application imports
from myapp.config import settings
from myapp.services.user import UserService
from myapp.utils.validation import validate_email

# Constants
API_VERSION: Final[str] = "v1"
MAX_RETRY_ATTEMPTS: Final[int] = 3

# ❌ BAD: Mixed import groups, inconsistent ordering
from myapp.config import settings
import logging
from fastapi import FastAPI
from typing import Final
import asyncio
from myapp.services.user import UserService
from pydantic import BaseModel
import httpx

Documentation with Google-Style Docstrings

# ✅ GOOD: Comprehensive Google-style docstring
from typing import Protocol
from decimal import Decimal

class PaymentProcessor(Protocol):
    """
    Protocol for payment processing implementations.
    
    Defines the interface that all payment processors must implement
    for handling transactions, refunds, and status queries.
    
    Attributes:
        provider_name: Name of the payment provider
        max_retry_attempts: Maximum number of retry attempts for failed transactions
        
    Example:
        >>> processor = StripePaymentProcessor(api_key="sk_test_123")
        >>> result = processor.process_payment(amount=Decimal("99.99"), currency="USD")
        >>> print(result.transaction_id)
        'txn_abc123'
    """
    
    provider_name: str
    max_retry_attempts: int
    
    def process_payment(
        self,
        amount: Decimal,
        currency: str,
        *,
        idempotency_key: str | None = None
    ) -> dict[str, str | Decimal]:
        """
        Process a payment transaction.
        
        Args:
            amount: Payment amount with precision to 2 decimal places
            currency: ISO 4217 currency code (e.g., 'USD', 'EUR')
            idempotency_key: Optional key for idempotent requests
            
        Returns:
            Dictionary containing:
                - transaction_id: Unique transaction identifier
                - status: Payment status ('success', 'pending', 'failed')
                - amount_charged: Actual amount charged including fees
                
        Raises:
            PaymentError: If payment processing fails
            ValueError: If amount is negative or currency is invalid
            
        Note:
            This method is idempotent when idempotency_key is provided.
            Retry logic uses exponential backoff up to max_retry_attempts.
        """
        ...

# ❌ BAD: Minimal or missing documentation
class PaymentProcessor:
    def process_payment(self, amount, currency, idempotency_key=None):
        # Process payment
        ...

Virtual Environment and Dependency Management

# ✅ GOOD: pyproject.toml with Poetry for modern dependency management
"""
[tool.poetry]
name = "myapp"
version = "1.0.0"
description = "Production application with proper dependencies"
authors = ["Team <team@example.com>"]
readme = "README.md"
python = "^3.11"

[tool.poetry.dependencies]
python = "^3.11"
fastapi = "^0.109.0"
uvicorn = {extras = ["standard"], version = "^0.27.0"}
pydantic = "^2.5.0"
httpx = "^0.26.0"
sqlalchemy = {extras = ["asyncio"], version = "^2.0.0"}

[tool.poetry.group.dev.dependencies]
pytest = "^7.4.0"
pytest-asyncio = "^0.23.0"
pytest-cov = "^4.1.0"
mypy = "^1.8.0"
ruff = "^0.1.0"
black = "^24.0.0"

[tool.poetry.group.test.dependencies]
pytest-mock = "^3.12.0"
faker = "^22.0.0"

[tool.black]
line-length = 88
target-version = ['py311']

[tool.mypy]
python_version = "3.11"
strict = true
warn_return_any = true
warn_unused_configs = true

[tool.ruff]
line-length = 88
target-version = "py311"
select = ["E", "F", "I", "N", "W", "B", "C4", "UP"]

[tool.pytest.ini_options]
asyncio_mode = "auto"
testpaths = ["tests"]
python_files = ["test_*.py"]
python_functions = ["test_*"]
addopts = "--cov=myapp --cov-report=term-missing --cov-report=html"

[build-system]
requires = ["poetry-core"]
build-backend = "poetry.core.masonry.api"
"""

# Project structure following src/ layout
"""
myapp/
├── .python-version          # pyenv version
├── pyproject.toml           # Project config and dependencies
├── poetry.lock              # Locked dependency versions
├── README.md
├── .gitignore
├── src/
│   └── myapp/
│       ├── __init__.py
│       ├── main.py
│       ├── config.py
│       ├── models/
│       ├── services/
│       ├── api/
│       └── utils/
├── tests/
│   ├── __init__.py
│   ├── conftest.py
│   ├── unit/
│   └── integration/
└── docs/
    └── api.md
"""

# ❌ BAD: requirements.txt without versions, flat structure
"""
fastapi
uvicorn
pydantic
httpx

myapp/
├── app.py
├── models.py
├── utils.py
└── test_app.py
"""

Configuration and Settings Management

# ✅ GOOD: Pydantic settings with environment validation
from functools import lru_cache
from typing import Literal

from pydantic import Field, HttpUrl, PostgresDsn, field_validator
from pydantic_settings import BaseSettings, SettingsConfigDict

class Settings(BaseSettings):
    """
    Application settings with environment variable support.
    
    Attributes are loaded from environment variables with APP_ prefix.
    Supports .env file loading in development.
    """
    
    model_config = SettingsConfigDict(
        env_file=".env",
        env_file_encoding="utf-8",
        env_prefix="APP_",
        case_sensitive=False,
        extra="ignore"
    )
    
    # Application
    environment: Literal["development", "staging", "production"] = "development"
    debug: bool = Field(default=False, description="Enable debug mode")
    log_level: Literal["DEBUG", "INFO", "WARNING", "ERROR"] = "INFO"
    
    # API
    api_title: str = "MyApp API"
    api_version: str = "1.0.0"
    api_host: str = "0.0.0.0"
    api_port: int = Field(default=8000, ge=1024, le=65535)
    
    # Database
    database_url: PostgresDsn = Field(
        ..., 
        description="PostgreSQL connection URL"
    )
    database_pool_size: int = Field(default=10, ge=5, le=50)
    
    # External Services
    external_api_url: HttpUrl
    external_api_key: str = Field(..., min_length=20)
    external_api_timeout: int = Field(default=30, ge=1, le=300)
    
    @field_validator("environment")
    @classmethod
    def validate_environment(cls, v: str) -> str:
        """Ensure environment is valid and warn on production."""
        if v == "production":
            import logging
            logging.warning("Running in PRODUCTION mode")
        return v

@lru_cache
def get_settings() -> Settings:
    """
    Get cached settings instance.
    
    Returns singleton settings object for dependency injection.
    """
    return Settings()

# Usage in FastAPI
from fastapi import Depends, FastAPI

app = FastAPI()

@app.get("/config")
async def get_config(settings: Settings = Depends(get_settings)) -> dict[str, str]:
    """Return non-sensitive configuration."""
    return {
        "environment": settings.environment,
        "api_version": settings.api_version,
        "log_level": settings.log_level,
    }

# ❌ BAD: Hardcoded configuration, no validation
import os

DEBUG = True
DATABASE_URL = "postgresql://user:pass@localhost/db"
API_KEY = "hardcoded-key-123"
TIMEOUT = 30

Anti-Patterns

Missing Type Hints

# ❌ Avoid: Dynamic typing without hints
def process_data(data, options):
    result = []
    for item in data:
        if options.get("transform"):
            result.append(item.upper())
    return result

# ✅ Fix: Add comprehensive type hints
from collections.abc import Sequence, Mapping
from typing import Any

def process_data(
    data: Sequence[str],
    options: Mapping[str, Any]
) -> list[str]:
    result: list[str] = []
    for item in data:
        if options.get("transform"):
            result.append(item.upper())
    return result

Inconsistent Import Ordering

# ❌ Avoid: Mixed import groups
from myapp.utils import helper
import json
from fastapi import FastAPI
import os
from typing import Dict

# ✅ Fix: Grouped and sorted imports (use isort)
import json
import os
from typing import Dict

from fastapi import FastAPI

from myapp.utils import helper

Mutable Default Arguments

# ❌ Avoid: Mutable default creates shared state
def add_item(item: str, items: list[str] = []) -> list[str]:
    items.append(item)
    return items

# ✅ Fix: Use None and create new instance
def add_item(item: str, items: list[str] | None = None) -> list[str]:
    if items is None:
        items = []
    items.append(item)
    return items

Global State Without Dependency Injection

# ❌ Avoid: Global configuration makes testing difficult
CONFIG = load_config()

def process_request(data: dict[str, Any]) -> dict[str, Any]:
    timeout = CONFIG["timeout"]
    return call_api(data, timeout)

# ✅ Fix: Use dependency injection
from fastapi import Depends

def process_request(
    data: dict[str, Any],
    config: Settings = Depends(get_settings)
) -> dict[str, Any]:
    return call_api(data, config.timeout)

Testing Strategies

Type Checking with mypy

# Run mypy with strict mode
mypy src/ --strict --warn-return-any --warn-unused-configs

# Check specific module
mypy src/myapp/services/user.py --strict

Code Formatting Validation

# Format with Black
black src/ tests/

# Check without modifying
black --check src/

# Sort imports with isort
isort src/ tests/

# Validate import sorting
isort --check-only src/

Linting with Ruff

# Run Ruff linter (faster alternative to flake8, pylint)
ruff check src/ tests/

# Auto-fix issues
ruff check --fix src/

# Check specific rules
ruff check src/ --select E,F,I,N,W,B

Testing Code Quality in CI/CD

# tests/test_code_quality.py
import subprocess
from pathlib import Path

def test_black_formatting() -> None:
    """Ensure code is formatted with Black."""
    result = subprocess.run(
        ["black", "--check", "src/"],
        capture_output=True
    )
    assert result.returncode == 0, "Code is not formatted with Black"

def test_mypy_type_checking() -> None:
    """Ensure type hints pass mypy validation."""
    result = subprocess.run(
        ["mypy", "src/", "--strict"],
        capture_output=True
    )
    assert result.returncode == 0, f"Type errors found:\n{result.stdout.decode()}"

def test_import_sorting() -> None:
    """Ensure imports are sorted with isort."""
    result = subprocess.run(
        ["isort", "--check-only", "src/"],
        capture_output=True
    )
    assert result.returncode == 0, "Imports are not sorted"

References