KAHIBARO
Discord Login Register

5.16 Python Backend Best Practices

Writing Maintainable Python Backend Code

This chapter focuses on practical habits and patterns that help you write Python backends that are readable, testable, and ready for production.

You have already seen core Python features and how to build web backends. Here you will connect that knowledge into concrete best practices.


Project and Code Organization

A clear structure makes it easy to find things and to grow your codebase safely.

Prefer a Package Structure

Even for small APIs, structure your project as a package:

text
myapp/
  pyproject.toml        # or setup.cfg / requirements.txt
  README.md
  .env.example
  myapp/
    __init__.py
    config.py
    main.py             # app entry point (FastAPI / Flask / etc.)
    api/
      __init__.py
      v1/
        __init__.py
        users.py
        items.py
    core/
      security.py
      logging.py
    models/
      __init__.py
      user.py
      item.py
    db/
      __init__.py
      session.py
      migrations/
    tests/
      __init__.py
      test_users.py
      test_items.py

Key ideas:

Separate Layers

Do not mix HTTP logic, business logic, and database logic in one function.

A common layering:

Example structure:

text
myapp/
  api/
    users.py        # FastAPI routes
  services/
    users.py        # business logic
  repositories/
    users.py        # DB operations

This separation keeps each part small, testable, and replaceable.


Code Style and Formatting

Consistent style helps teams read and maintain code.

Use a Formatter

Use a formatter like Black to format Python code automatically.

bash
pip install black
black myapp

Black enforces things like:

You focus on logic, not spacing.

Lint Your Code

Use linters to catch problems early:

bash
pip install ruff mypy
ruff myapp
mypy myapp

Example of a linter catch:

python
# Bug: unused variable, shadowed name, etc.
def find_user(id, db):
    id = int(id)
    return db.get_user()

A linter will point to the suspicious id = int(id) and unused id.


Type Hints and Static Checking

Type hints make your code safer and easier to understand.

Add Type Hints to Functions

python
from typing import Optional, List
from dataclasses import dataclass
@dataclass
class User:
    id: int
    email: str
    is_active: bool = True
def get_user_by_id(user_id: int) -> Optional[User]:
    ...

Use them for:

Use mypy or Pyright

Type check your project:

bash
mypy myapp

Example issue:

python
def send_email(user_email: str) -> None:
    ...
user_id: int = 123
send_email(user_id)   # mypy: Argument 1 has incompatible type "int"; expected "str"

You catch bugs before running the code.

Rule: In backend code, add type hints to all public functions and methods and run a type checker regularly.


Configuration and Environment

Never hardcode secrets or environment-specific values in code.

Use Environment Variables

Typical config values:

Use pydantic or similar to load them once:

python
from pydantic import BaseSettings
class Settings(BaseSettings):
    debug: bool = False
    database_url: str
    secret_key: str
    class Config:
        env_file = ".env"
settings = Settings()

Then use:

python
from myapp.config import settings
engine = create_engine(settings.database_url)

Do Not Commit Secrets

Maintain an .env.example:

text
DEBUG=false
DATABASE_URL=postgresql+psycopg2://user:pass@localhost:5432/mydb
SECRET_KEY=your-secret-key-here

And keep real .env files out of version control with .gitignore.

Rule: Never commit real passwords, secret keys, tokens, or private keys to Git.


Dependency Management

Keep dependencies under control and reproducible.

Use a Requirements or pyproject File

Two common approaches:

  1. requirements.txt:
bash
pip freeze > requirements.txt
pip install -r requirements.txt
  1. pyproject.toml with tools like Poetry or Hatch:
toml
[project]
name = "myapp"
version = "0.1.0"
dependencies = [
  "fastapi",
  "uvicorn[standard]",
  "sqlalchemy",
  "psycopg2-binary",
]

Pin Versions

For production apps, pin versions:

text
fastapi==0.115.0
uvicorn[standard]==0.30.0
sqlalchemy==2.0.32
psycopg2-binary==2.9.9

This avoids surprises when libraries change.


Error Handling and Exceptions

Backends must fail in controlled, predictable ways.

Raise Domain-Specific Exceptions

Instead of using generic Exception, define your own:

python
class UserNotFoundError(Exception):
    pass
class EmailAlreadyUsedError(Exception):
    pass

Service layer:

python
from .exceptions import UserNotFoundError, EmailAlreadyUsedError
def register_user(email: str, password: str) -> User:
    if user_repo.exists(email=email):
        raise EmailAlreadyUsedError(f"Email {email} already used")
    ...

Then map these to HTTP errors in the API layer.

Never Hide Errors Silently

Avoid:

python
try:
    do_something()
except Exception:
    pass

Better:

python
import logging
logger = logging.getLogger(__name__)
try:
    do_something()
except SpecificError as exc:
    logger.exception("Failed to do something")
    raise

You can handle known errors, but log them clearly.


Logging

Logging is your main window into a running backend.

Use the Standard Logging Module

Configure logging once, for example in myapp/core/logging.py:

python
import logging
import sys
def configure_logging() -> None:
    logging.basicConfig(
        level=logging.INFO,
        format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
        handlers=[
            logging.StreamHandler(sys.stdout),
        ],
    )

Call configure_logging() in your entry point:

python
from myapp.core.logging import configure_logging
def create_app():
    configure_logging()
    ...

Use Log Levels Properly

LevelUse for
DEBUGDetailed info for debugging, not in prod by default
INFOHigh-level application events
WARNINGSomething unexpected but continuing
ERRORA failure that affects a request or workflow
CRITICALSerious errors, application may be unusable

Example:

python
logger = logging.getLogger(__name__)
def create_order(...):
    logger.info("Creating order for user_id=%s", user_id)
    try:
        ...
    except PaymentError as exc:
        logger.error("Payment failed for user_id=%s: %s", user_id, exc)
        raise

Database Access and ORM Best Practices

Databases are often the bottleneck and failure point.

Use a Session per Request

Do not share a single global session for all requests.

With SQLAlchemy:

python
from sqlalchemy.orm import sessionmaker
SessionLocal = sessionmaker(bind=engine, autocommit=False, autoflush=False)
def get_db():
    db = SessionLocal()
    try:
        yield db
    finally:
        db.close()

In FastAPI:

python
@app.get("/users/{user_id}")
def read_user(user_id: int, db: Session = Depends(get_db)):
    return user_repo.get(db, user_id)

Keep Queries Close to the Repository

In repositories/users.py:

python
from sqlalchemy.orm import Session
from myapp.models.user import User
def get(db: Session, user_id: int) -> User | None:
    return db.query(User).filter(User.id == user_id).first()
def get_by_email(db: Session, email: str) -> User | None:
    return db.query(User).filter(User.email == email).first()

Service code should rarely write raw queries itself, and API code should never talk to the database directly.


Testing and Testability

A backend without tests is fragile.

Write Tests from the Start

Examples:

Simple service test:

python
def test_register_user_creates_user(user_service, fake_db):
    user = user_service.register_user("test@example.com", "password123")
    assert user.email == "test@example.com"

Prefer Injected Dependencies

Instead of:

python
# Hard to test
def send_welcome_email(user: User) -> None:
    smtp = SmtpClient("smtp.example.com", 587)
    smtp.send(...)

Use dependency injection:

python
class EmailSender:
    def send_welcome_email(self, user: User) -> None:
        ...
def create_user(email: str, password: str, mailer: EmailSender) -> User:
    user = user_repo.create(email=email, password=password)
    mailer.send_welcome_email(user)
    return user

In tests, pass a fake EmailSender that records calls.

Rule: Design functions so that external services are passed in as parameters. This makes code testable and reusable.


Performance and Asynchronous Code

Backend performance often depends on I/O, such as database calls or HTTP requests.

Use Async When Appropriate

In async frameworks like FastAPI, define handlers as async def when:

Example:

python
@app.get("/items/{item_id}")
async def read_item(item_id: int):
    item = await item_service.get_item(item_id)
    return item

Keep in mind:

Avoid Premature Optimization

Focus on:

  1. Clear, simple code.
  2. Correctness and tests.
  3. Measure performance with profiling or load tests.
  4. Then optimize real bottlenecks.

Security-Oriented Habits

Security should be part of your everyday coding.

Validate Inputs

For APIs:

Example:

python
from pydantic import BaseModel, EmailStr, constr
class UserCreate(BaseModel):
    email: EmailStr
    password: constr(min_length=8, max_length=128)

Least Privilege Principle

Rule: Assume all external input can be malicious. Always validate, sanitize, and authorize.


API Design and Error Responses

Design your REST API to be predictable.

Use Consistent URL Patterns

Examples:

Keep nouns plural, avoid verbs in paths when possible.

Standardize Error Responses

Return structured errors:

json
{
  "detail": "User not found",
  "code": "user_not_found"
}

For validation errors, use a predictable schema, for example:

json
{
  "detail": [
    {
      "loc": ["body", "email"],
      "msg": "value is not a valid email address",
      "type": "value_error.email"
    }
  ]
}

This helps frontend and other clients handle errors reliably.


Documentation and Comments

Good documentation lowers the cost of onboarding and maintenance.

Write Docstrings for Public Functions

Example:

python
def create_user(email: str, password: str) -> User:
    """
    Create a new user with the given email and password.
    Raises:
        EmailAlreadyUsedError: If a user with the same email already exists.
    """
    ...

Keep Comments Focused

Bad:

python
# Increase x by 1
x = x + 1

Good:

python
# We add 1 to include the current page in the total count
x = x + 1

Putting It All Together: Example Flow

A typical request path in a well-structured Python backend:

  1. Router receives HTTP request:
    • Parses path parameters, query, and body.
    • Validates request model.
  2. Service function:
    • Applies business rules.
    • Calls repositories for DB operations.
    • Uses injected dependencies such as mailers or external APIs.
    • Raises domain-specific exceptions on errors.
  3. Repository:
    • Executes ORM or SQL queries.
    • Maps rows to domain objects or models.
  4. Exception handlers:
    • Translate domain exceptions into HTTP responses.
  5. Response model:
    • Serializes data into JSON with correct schema.

Each step is small, testable, and uses:

Checklist of Python Backend Best Practices

Use this as a quick reference when building backends in Python.

Backend Best Practices Checklist

  • Project structure:
    • Organized by responsibility, not huge files.
    • Separate API, service, and repository layers.
  • Code quality:
    • Automatic formatter (Black).
    • Linters (Ruff, Flake8) and type checker (mypy).
  • Types:
    • Type hints on public functions and models.
    • Regular static type checking.
  • Configuration:
    • Environment variables for secrets and environment-specific values.
    • No secrets in Git, .env.example committed.
  • Dependencies:
    • Requirements or pyproject.toml with pinned versions for production.
  • Error handling:
    • Domain-specific exceptions, not bare Exception.
    • No silent exception swallowing, always log or handle.
  • Logging:
    • Central logging configuration.
    • Correct use of log levels.
  • Persistence:
    • One DB session per request, properly closed.
    • Repositories for all DB access.
  • Testing:
    • Unit tests and integration tests.
    • Dependencies injected, not hardcoded, for easy mocking.
  • Performance:
    • Async interfaces where useful, avoid blocking operations in async code.
    • Measure before optimization.
  • Security:
    • Input validation on all external data.
    • Least privilege for secrets and permissions.
  • API:
    • Consistent URL patterns.
    • Structured, predictable error responses.
  • Documentation:
    • Docstrings and concise comments explaining why.

Following these practices will help you build Python backends that are robust, maintainable, and production ready.

Views: 8

Comments

Please login to add a comment.

Don't have an account? Register now!