Samuel Oshin
Back to Blog
Python Backend Best Practices in 2026

Python Backend Best Practices in 2026

Battle-tested patterns for building production Python backends — async architectures, dependency injection, structured logging, and the tools that actually matter.

10 min read
PythonBackendFastAPIArchitecture

The Python Backend Landscape in 2026

Python's backend ecosystem has matured dramatically. FastAPI is now the de facto standard for new projects, async/await is everywhere, and the tooling around type safety has caught up with statically-typed languages. Here are the patterns I use on every production project.

Project Structure That Scales

After working on multiple production backends, I've converged on this structure:

src/
├── api/
│   ├── routes/          # Route handlers (thin — delegate to services)
│   ├── dependencies.py  # FastAPI dependency injection
│   └── middleware.py    # Auth, logging, CORS
├── core/
│   ├── config.py        # Settings via pydantic-settings
│   ├── security.py      # JWT, hashing, auth flows
│   └── exceptions.py    # Custom exception hierarchy
├── models/
│   ├── domain/          # Business logic models
│   └── schemas/         # Pydantic request/response schemas
├── services/            # Business logic layer
├── repositories/        # Data access layer
└── infrastructure/      # External integrations (DB, cache, queues)

The key principle: routes are thin, services are fat. A route handler should do three things — validate input, call a service, return a response. I go deeper into this pattern in my post on building FastAPI templates for AI agents.

Async Done Right

The biggest mistake I see is mixing sync and async code without understanding the implications.

# ❌ BAD: Blocking the event loop
@app.get("/users/{user_id}")
async def get_user(user_id: int):
    # This blocks the entire event loop!
    user = db.query(User).filter(User.id == user_id).first()
    return user
 
# ✅ GOOD: Properly async with async DB driver
@app.get("/users/{user_id}")
async def get_user(user_id: int, db: AsyncSession = Depends(get_db)):
    result = await db.execute(select(User).where(User.id == user_id))
    return result.scalar_one_or_none()
 
# ✅ ALSO GOOD: Sync function (FastAPI runs it in a thread pool)
@app.get("/users/{user_id}")
def get_user(user_id: int, db: Session = Depends(get_db)):
    return db.query(User).filter(User.id == user_id).first()

Rule of thumb: If your handler calls any await, make it async def. If it's all synchronous, use plain def — FastAPI will run it in a thread pool automatically.

Dependency Injection

FastAPI's Depends() is one of its best features, but most codebases underuse it. Here's how I structure dependencies:

from fastapi import Depends
from sqlalchemy.ext.asyncio import AsyncSession
 
async def get_db() -> AsyncGenerator[AsyncSession, None]:
    async with async_session_maker() as session:
        yield session
 
async def get_current_user(
    token: str = Depends(oauth2_scheme),
    db: AsyncSession = Depends(get_db),
) -> User:
    payload = verify_jwt(token)
    user = await user_repo.get_by_id(db, payload["sub"])
    if not user:
        raise HTTPException(401, "User not found")
    return user
 
def require_role(role: str):
    async def _check(user: User = Depends(get_current_user)):
        if role not in user.roles:
            raise HTTPException(403, f"Requires {role} role")
        return user
    return _check
 
# Usage — clean and composable
@app.delete("/admin/users/{id}")
async def delete_user(
    id: int,
    admin: User = Depends(require_role("admin")),
    db: AsyncSession = Depends(get_db),
):
    await user_service.delete(db, id)

Structured Logging

Print statements and logging.info("something happened") don't cut it in production. Use structured logging:

import structlog
 
logger = structlog.get_logger()
 
@app.middleware("http")
async def log_requests(request: Request, call_next):
    start = time.perf_counter()
    response = await call_next(request)
    duration = time.perf_counter() - start
    
    logger.info(
        "http_request",
        method=request.method,
        path=request.url.path,
        status=response.status_code,
        duration_ms=round(duration * 1000, 2),
        user_agent=request.headers.get("user-agent"),
    )
    return response

This outputs JSON logs that your observability stack (Datadog, Grafana, ELK) can parse and query:

{
  "event": "http_request",
  "method": "GET",
  "path": "/api/users/42",
  "status": 200,
  "duration_ms": 12.34,
  "timestamp": "2026-02-25T10:30:00Z"
}

Error Handling Strategy

Define a clear exception hierarchy and handle errors consistently:

# core/exceptions.py
class CustomDomainException(Exception):
    code: str = "INTERNAL_ERROR"
    message: str = "An unexpected error occurred"
    status_code: int = 500
 
class NotFoundError(CustomDomainException):
    code = "NOT_FOUND"
    message = "Resource not found"
    status_code = 404
 
class InvalidCredentialsError(CustomDomainException):
    code = "INVALID_CREDENTIALS"
    message = "Invalid username or password"
    status_code = 401
 
# Register a global handler — all exceptions funnel here
@app.exception_handler(CustomDomainException)
async def domain_error_handler(request: Request, exc: CustomDomainException):
    return JSONResponse(
        status_code=exc.status_code,
        content=error_response(exc.status_code, exc.message, exc.code),
    )

Services raise, routes never catch. The global handler converts every domain error into the standard response shape — clients never see a raw Python exception.

API Response Format: The Standard Your Clients Deserve

One of the most impactful (and most overlooked) production decisions is locking down your response envelope. If every endpoint returns a slightly different shape, your frontend team lives in constant pain and your API is unpredictable to AI agents and third-party clients.

The Rules

REST Naming Conventions

  • Use plural nouns for resources: GET /users, POST /orders, DELETE /projects/{id}
  • Nest for ownership: GET /organizations/{org_id}/projects
  • Use query params for filtering: GET /projects?status=active&page=2
  • Never use verbs in paths: /get-user or /create_user are wrong. The HTTP method is the verb.

HTTP Status Codes That Matter

CodeMeaningWhen to Use
200OKSuccessful GET / action with response body
201CreatedSuccessful POST (resource created)
204No ContentSuccessful DELETE with no body
400Bad RequestMalformed input / business rule violation
401UnauthorizedMissing or invalid auth token
403ForbiddenAuthenticated but lacks permission
404Not FoundResource doesn't exist
409ConflictDuplicate resource (e.g., email already registered)
422Unprocessable EntityPydantic / semantic validation failure
429Too Many RequestsRate limit exceeded
500Internal Server ErrorUnexpected server-side failure
503Service UnavailableDownstream dependency unreachable

Standard Success Response Shape

Every success response uses the same four keys — no exceptions:

{
  "status": "success",
  "status_code": 200,
  "message": "Users retrieved",
  "data": {
    "items": [{ "id": 1, "name": "Alice" }]
  },
  "meta": { "page": 1, "per_page": 20, "total": 120 }
}
  • data is always an object, never null and never a raw array. This makes responses schema-stable — adding fields doesn't break existing clients.
  • meta is optional and used for pagination.
  • For auth flows, include tokens under data:
{
  "status": "success",
  "status_code": 201,
  "message": "Authenticated",
  "data": {
    "access_token": "eyJhbGc...",
    "user": { "id": 42, "email": "alice@example.com" }
  }
}

Standard Error Response Shape

Errors are equally predictable — and framework-agnostic (no detail key from FastAPI's default, no stack traces):

{
  "status": "failure",
  "status_code": 422,
  "message": "Validation failed",
  "error_code": "VALIDATION_ERROR",
  "errors": {
    "email": ["Invalid email format"],
    "password": ["Must be at least 8 characters"]
  }
}
{
  "status": "failure",
  "status_code": 404,
  "message": "Project not found",
  "error_code": "NOT_FOUND",
  "errors": {}
}

errors is always an object or array, never omitted and never null. This way clients can always safely iterate it.

Implementation: Centralized Response Helpers

Never return raw dicts from your routes. Centralize the shape in helper functions:

# core/response_payloads.py
from fastapi.responses import JSONResponse
from typing import Any
 
def success_response(
    status_code: int,
    message: str,
    data: dict = {},
    meta: dict | None = None,
) -> JSONResponse:
    body = {
        "status": "success",
        "status_code": status_code,
        "message": message,
        "data": data,
    }
    if meta:
        body["meta"] = meta
    return JSONResponse(status_code=status_code, content=body)
 
def error_response(
    status_code: int,
    message: str,
    error_code: str = "INTERNAL_ERROR",
    errors: dict | list = {},
) -> dict:
    return {
        "status": "failure",
        "status_code": status_code,
        "message": message,
        "error_code": error_code,
        "errors": errors,
    }

Your routes become one-liners:

@router.get("/users")
async def list_users(db: AsyncSession = Depends(get_db)):
    users = await user_service.get_all(db)
    return success_response(200, "Users retrieved", data={"items": users})

Writing Framework-Agnostic Error Messages

This matters more than it sounds. Your error responses must not reveal which framework or language you're using. If you switch from FastAPI to Django tomorrow, no client should be able to tell.

# ❌ BAD: leaks internal Python details
raise HTTPException(422, detail={"loc": ["body", "email"], "msg": "value is not a valid email"})
 
# ❌ BAD: exposes DB internals
raise ValueError("User record not found in users table")
 
# ✅ GOOD: user-facing, framework-agnostic
raise InvalidInputError("Please enter a valid email address.")
raise NotFoundError("The requested project could not be found.")

Exception message rules:

  • ✅ Use clear, non-technical language: "Invalid username or password"
  • ✅ Be specific about next steps: "Your account is locked. Try again in 30 minutes."
  • ❌ Never reveal DB structure: "User not found in users table"
  • ❌ Never expose infrastructure: "Redis connection timeout"
  • ❌ Never include raw tracebacks or exception class names

Error code naming convention — always UPPERCASE_SNAKE_CASE:

PatternExampleUse Case
{PROBLEM}INVALID_CREDENTIALS, NOT_FOUNDSpecific problem
{RESOURCE}_{STATE}ACCOUNT_INACTIVE, RESOURCE_LOCKEDResource state
{ACTION}_ERRORPROCESSING_ERROR, EXTRACTION_FAILEDOperation failed
{ACTION}_{TYPE}TOKEN_EXPIRED, RATE_LIMIT_EXCEEDEDSpecific scenario

Common Mistakes I See in Production Codebases

These are the anti-patterns I encounter most frequently when reviewing Python backends. Avoid them.

1. Catching Every Exception Silently

# ❌ BAD: This swallows bugs and makes debugging a nightmare
try:
    result = await some_critical_operation()
except Exception:
    pass  # "it's fine, probably"

If you catch Exception, at minimum log it and re-raise a domain-specific error. Silent failures are the most expensive bugs to diagnose in production.

2. Business Logic in Route Handlers

# ❌ BAD: 80 lines of logic jammed into a route
@router.post("/orders")
async def create_order(payload: OrderRequest, db = Depends(get_db)):
    user = await db.execute(select(User).where(...))
    inventory = await db.execute(select(Product).where(...))
    if inventory.quantity < payload.quantity:
        raise HTTPException(400, "Not enough stock")
    # ... 60 more lines of pricing, discounts, notifications ...

This makes it untestable (you need a running HTTP server), unreusable (can't call from a background job), and makes AI agents terrified to touch it. Extract to a service class. I wrote an entire post on how to architect this properly for AI agents.

3. Hardcoding Configuration Values

# ❌ BAD: Magic numbers and strings everywhere
redis_client = Redis(host="172.16.0.5", port=6379, db=2)
JWT_SECRET = "my-super-secret-key-123"

Use pydantic-settings to load config from environment variables with type validation and defaults. Never commit secrets.

4. Ignoring Database Connection Pooling

# ❌ BAD: Creating a new connection per request
async def get_db():
    engine = create_async_engine(DATABASE_URL)  # new engine every time!
    async with AsyncSession(engine) as session:
        yield session

Create the engine once at startup. Set sane pool sizes (pool_size=20, max_overflow=10). Connection creation is expensive — a misconfigured pool under load will bring your service down faster than any bug.

5. No Request Validation Beyond Pydantic

Pydantic validates shape and types, but not business rules. Don't rely on it alone:

# Pydantic will accept this, but it's bad data
class TransferRequest(BaseModel):
    amount: float  # ❌ Allows 0.0, -500, 999999999
    
# ✅ GOOD: Add business validation
class TransferRequest(BaseModel):
    amount: float
 
    @field_validator("amount")
    @classmethod
    def validate_amount(cls, v):
        if v <= 0:
            raise ValueError("Amount must be positive")
        if v > 100_000:
            raise ValueError("Amount exceeds single transfer limit")
        return round(v, 2)

The Tools That Actually Matter

After years of trying every new tool, here's my opinionated production stack:

CategoryToolWhy
FrameworkFastAPIType safety, async, auto-docs
ORMSQLAlchemy 2.0Async support, mature, typed
MigrationsAlembicThe only real option
ValidationPydantic v25-50x faster than v1
Testingpytest + httpxAsync test client support
LintingRuffReplaces flake8, isort, black
Type checkingmypy (strict)Catches bugs before runtime
Task queueCelery or ARQARQ for async, Celery for everything else
CachingRedisUniversal, fast, versatile

Final Thoughts

The best backend code is boring code. Use established patterns, keep handlers thin, test the important paths, and invest in observability. The exciting work should happen in your business logic, not your infrastructure.

If you're building systems that AI agents need to work with, check out my guide on structuring FastAPI for AI assistants and how Library Agent Skills are changing the way libraries communicate with LLMs.


Have questions about Python backend architecture? Let's connect — I'm always up for a good architecture debate.