· Jonathan Cutrer · Engineering  · 3 min read

The FastAPI Boilerplate I Keep Copying Between Projects

A minimal, opinionated starting point for FastAPI services — structured logging, health endpoints, lifespan events, and nothing else.

A minimal, opinionated starting point for FastAPI services — structured logging, health endpoints, lifespan events, and nothing else.

I’ve started enough FastAPI projects that I have a template I copy every time. Not a cookiecutter, not a GitHub template repo — just a file I keep around and paste from when something new needs standing up quickly.

Here’s the version I’m currently using, with some notes on why each piece is there.

The Entrypoint

import logging
import os
from contextlib import asynccontextmanager

import uvicorn
from fastapi import FastAPI, Request
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse

logging.basicConfig(
    level=logging.INFO,
    format="%(asctime)s %(levelname)s %(name)s %(message)s",
)
logger = logging.getLogger(__name__)

Structured logging from the top. I’ve been burned too many times by services that log nothing useful when something breaks at 2am. The format string matters — %(name)s tells you which module logged the message, which is critical when you have more than two files.

Lifespan Events

@asynccontextmanager
async def lifespan(app: FastAPI):
    # Startup
    logger.info("Starting up — connecting to database")
    app.state.db_pool = await create_db_pool()
    logger.info("Database pool ready")
    yield
    # Shutdown
    logger.info("Shutting down — closing database pool")
    await app.state.db_pool.close()

The lifespan pattern replaced the old @app.on_event("startup") decorator in FastAPI 0.95+. Use it. It’s cleaner, it handles startup exceptions more predictably, and it makes the startup/shutdown lifecycle explicit rather than scattered across decorators.

app.state is the right place to hang things like database pools — it survives the lifespan of the application and is accessible from request handlers via request.app.state.db_pool.

The App

app = FastAPI(
    title="Service Name",
    version="0.1.0",
    lifespan=lifespan,
)

app.add_middleware(
    CORSMiddleware,
    allow_origins=os.getenv("CORS_ORIGINS", "").split(","),
    allow_credentials=True,
    allow_methods=["*"],
    allow_headers=["*"],
)

The CORS_ORIGINS env var keeps CORS config out of code. In development it’s usually http://localhost:3000,http://localhost:4321. In production, it’s whatever the frontend actually lives at.

Health Endpoint

@app.get("/health", tags=["ops"])
async def health_check(request: Request):
    db_ok = False
    try:
        async with request.app.state.db_pool.acquire() as conn:
            await conn.fetchval("SELECT 1")
        db_ok = True
    except Exception as e:
        logger.warning(f"Database health check failed: {e}")

    status = "ok" if db_ok else "degraded"
    return JSONResponse(
        status_code=200 if db_ok else 503,
        content={"status": status, "db": db_ok},
    )

Health endpoints are the thing I add to every service and the thing I’ve seen left out of production services more times than I can count. Two rules: it has to actually check the database (not just return 200 unconditionally), and it has to return a non-2xx status when something is broken so your load balancer can route around it.

Exception Handler

@app.exception_handler(Exception)
async def unhandled_exception_handler(request: Request, exc: Exception):
    logger.error(
        f"Unhandled exception: {exc}",
        exc_info=True,
        extra={"path": request.url.path, "method": request.method},
    )
    return JSONResponse(
        status_code=500,
        content={"detail": "Internal server error"},
    )

The exc_info=True flag includes the full traceback in the log entry. The extra dict adds structured fields that log aggregation tools (Datadog, Loki, CloudWatch) can filter on. This has saved me hours when tracking down intermittent 500s in production.

Running It

if __name__ == "__main__":
    uvicorn.run(
        "main:app",
        host="0.0.0.0",
        port=int(os.getenv("PORT", "8000")),
        reload=os.getenv("ENV", "production") == "development",
        log_level="info",
    )

reload=True only in development — the env var keeps this from accidentally running with hot reload in prod. PORT as an env var is standard for anything that deploys to a container or PaaS.

What’s Deliberately Missing

No auth, no database migrations, no router imports. Those come in once you know what the service is actually doing. Adding them to a boilerplate just means deleting them half the time.

The goal is a service that starts up, checks its own dependencies, logs sensibly, and doesn’t swallow exceptions silently. Everything beyond that is domain-specific.

Back to Blog

Related Posts

View All Posts »