Mastering FastAPI quality standards with SonarQube

Jean Jimbo photo

Jean Jimbo

Product manager (Code Quality)

12 min read

  • Code quality
  • Languages & frameworks

Table of contents

  • Chevron right iconQuality pillar 1: Contract precision & data ingress
  • Chevron right iconQuality pillar 2: Runtime wiring and lifecycle management

Start your free trial

Verify all code. Find and fix issues faster with SonarQube.

Get started

Modern web development often treats HTTP requests as unstructured "blobs" of data. While this mindset might pass in legacy frameworks, it is fundamentally incompatible with the high-performance asynchronous environment of FastAPI. Failing to respect the web development framework's internal mechanics leads to protocol mismatches, broken request lifecycles, and avoidable code security exposures.

In this post, we will audit a hypothetical Enterprise Policy Manager API. We will examine how specific SonarQube rules help us refine our approach to transform a brittle implementation into a professional standard.

Quality pillar 1: Contract precision & data ingress

The most common failures in APIs stem from ambiguity. Because Python is dynamic, software developers often assume the framework will "figure it out," but HTTP protocols are rigid. When your code creates a mismatch between the HTTP definition (OpenAPI) and the Python runtime, you generate bugs that surface on the first request.

This implementation attempts to upload a policy document with metadata but fails to define a strict interface.

# Anti-patterns to avoid

from fastapi import FastAPI, Body, File, UploadFile, HTTPException

from typing import Optional, List

from pydantic import BaseModel

app = FastAPI()

class PolicyMeta(BaseModel):

    # S8396: Optional without a default implies it is required if missing

    tags: Optional[List[str]] 

# S8409: Redundant response_model (FastAPI infers this from return annotation)

@app.post("/policies/{policy_id}", methods=["POST"], response_model=dict)

async def create_policy(

    # S8411: 'policy_id' is in path but missing from function signature

    # S8410: Body() used as a default value

    meta: PolicyMeta = Body(...), 

    # S8389: Mixing Body (JSON) with File (Multipart) causes encoding conflicts

    # S8410: File() used as a default value

    files: UploadFile = File(...) 

) -> dict:

    if not files:

        # S8415: Exception raised but never documented in OpenAPI

        raise HTTPException(status_code=400, detail="No file")

    return {"status": "uploaded"}

There are several pitfalls here, including:

  1. The "Optional" tTrap (S8396): Optional[List[str]] only means None is a valid value—it does not make the field optional during validation. Without an explicit = None, Pydantic still demands the field be present in the payload.
  2. Redundant Models (S8409): Specifying response_model when it duplicates the return type annotation adds maintenance burden and visual noise without value.
  3. Missing Path Parameters (S8411): FastAPI relies on the function signature to inject values. If {policy_id} is in the decorator but not the function signature, FastAPI will raise a ValueError at startup before any other request is served. The route compilation step cross-references every {param} in the path template against the function signature, and any mismatch is a hard startup failure. .
  4. The False Default (S8410): Body() and File() look like default values but aren't— - the parameter's actual type is hidden; use Annotated[Type, Body(...)] instead.
  5. The Content-Type Clash (S8389): You cannot mix Body() and File(). Body expects application/json, while File requires multipart/form-data. The server cannot parse JSON from a multipart stream natively, leading to 422 Validation Errors.
  6. The Ghost Exception (S8415): Raising an HTTPException inside a function logic without declaring it in the decorator creates "Dark Documentation." Integration teams relying on your Swagger UI won't know they need to handle a 400 error, leading to unhandled crashes in the frontend.

Let’s refactor using Form data for complex structures alongside files, strict default values, and explicit exception documentation:

from fastapi import FastAPI, Form, File, UploadFile, HTTPException, status

from pydantic import BaseModel, model_validator

from typing import List, Optional, Annotated

import json

app = FastAPI()

class PolicyMeta(BaseModel):

    # S8396: Explicit default makes it truly optional during validation

    tags: Optional[List[str]] = None

    # S8389: Validator handles parsing JSON strings from Form data

    @model_validator(mode='before')

    @classmethod

    def validate_to_json(cls, value):

        if isinstance(value, str):

            return cls(**json.loads(value))

        return value

# S8415: Document the exception explicitly in responses map

@app.post(

    "/policies/{policy_id}", 

    responses={400: {"description": "File missing"}}

)

async def create_policy(

    # S8411: Path parameter MUST be in the signature

    policy_id: str, 

    # S8389: Use Form() for structured data alongside files

    meta: Annotated[PolicyMeta, Form()], 

    files: Annotated[UploadFile, File()]

) -> dict:

    return {"status": "uploaded"}

    # S8415: The exception is now documented above

    if not files.filename:

        raise HTTPException(status_code=400, detail="No file provided")

    return {"status": "uploaded"}

Testing Your Contract (S8405): When testing this endpoint, use the content parameter for raw bytes or pre-serialized JSON strings. Using data for anything other than a dictionary (form fields) can lead to incorrect encoding with the httpx-based TestClient.

Quality pillar 2: Runtime wiring and lifecycle management

How you assemble the application is just as critical as the code within it. Middleware layering, router registration, and process binding define the security and stability of the runtime environment.

This setup uses sub-routers and middleware, but the ordering destroys functionality and the binding configuration is insecure:

import uvicorn

from fastapi import APIRouter, FastAPI, Response

from fastapi.middleware.cors import CORSMiddleware

from fastapi.middleware.gzip import GZipMiddleware

# S8413: Defining prefix late in include_router

policy_router = APIRouter() 

admin_router = APIRouter()

app = FastAPI()

@policy_router.delete("/cleanup", status_code=204)

def cleanup():

    # S8400: 204 means No Content, but this might return 'null' (4 bytes)

    pass 

# S8401: Router registered BEFORE child routes are added

app.include_router(policy_router, prefix="/api/v1") 

# S8401: Child router added too late; app already registered policy_router

policy_router.include_router(admin_router) 

# S8414: CORS added first (inner layer), GZip wraps it

app.add_middleware(

    CORSMiddleware, 

    allow_origins=["*"],

    allow_methods=["*"]

) 

app.add_middleware(GZipMiddleware) 

if __name__ == "__main__":

    # S8392: Binding to 0.0.0.0 exposes dev machine to network

    # S8397: Passing app object prevents multiprocessing/reload

    uvicorn.run(app, host="0.0.0.0", reload=True)

The snippet contains the following pitfalls:

  1. The Wandering Prefix (S8413): Defining the prefix in include_router() rather than APIRouter() separates a router's URL structure from its definition. Anyone reading the router file has no idea where its routes live without hunting through the application setup.
  2. Phantom 204 Bodies (S8400): HTTP 204 means "No Content." When a function body ends with pass or ...,Python implicitly returns None - but FastAPI’s serialization pipeline sees an unhandled return path and may still emit a null body (4 bytes). Explicitly writing return None signals to FastAPI that the absence of content is intentional, allowing it to bypass serialization entirely. The safer alternative, return Response(status _code=204), bypasses FastAPI’s serialization layer altogether and guarantees an empty body regardless of framework version. 
  3. The Registration Timeline (S8401): FastAPI registers routes at the moment include_router is called. If you add admin_router to policy_router after policy_router is added to app, the admin routes are invisible (404s).
  4. The Middleware Problem (S8414): Middleware wraps the application. The last added middleware is the outermost layer. If GZip is added after CORS, GZip handles the request first. If GZip rejects a request, the inner CORS layer never runs, and the browser receives a CORS error instead of the actual error.
  5. The Open Door (S8392): Binding 0.0.0.0 exposes your application on every available attack surface, including public ones. Bind to 127.0.0.1 instead.
  6. Pickling Problems (S8397): uvicorn.run(app) passes the Python object directly. New worker processes have no way to reconstruct it. An import string like "main:app” tells each worker how to import the application independently, enabling both reload and multiple workers.

Let’s correct these issues. We build the router hierarchy bottom-up, layer middleware like an onion (CORS on the outside), and use import strings for the runner.

import uvicorn

from fastapi import APIRouter, FastAPI, Response

from fastapi.middleware.cors import CORSMiddleware

from fastapi.middleware.gzip import GZipMiddleware

# S8413: Define prefixes at initialization for a Single Source of Truth

admin_router = APIRouter(prefix="/admin")

policy_router = APIRouter(prefix="/api/v1")

@policy_router.delete("/cleanup", status_code=204)

def cleanup():

    # S8400: Explicitly return Response or None to ensure empty body

    return Response(status_code=204)

# S8401: Include child routers BEFORE including the parent in the app

policy_router.include_router(admin_router)

app = FastAPI()

# S8401: Now that policy_router is fully assembled, we include it

app.include_router(policy_router)

# S8414: Add other middleware FIRST (Inner layers)

app.add_middleware(GZipMiddleware)

# S8414: Add CORSMiddleware LAST (Outermost layer)

# This ensures CORS headers are applied to all responses, even errors

app.add_middleware(

    CORSMiddleware,

    allow_origins=["https://trusted-client.com"], 

    allow_credentials=True,

    allow_methods=["*"],

    allow_headers=["*"],

)

if __name__ == "__main__":

    # S8392: Bind to localhost (127.0.0.1) for development security

    # S8397: Pass import string "main:app" to enable reload/workers

    uvicorn.run("main:app", host="127.0.0.1", port=8000, reload=True)

Before implementing your next route, always consider if your implementation relies on a framework coincidence or an explicitly defined contract. The most resilient services are built on the latter.

Check out the details of all 14 new FastAPI rules in our community post.

Build trust into every line of code

Image for rating

4.6 / 5

Get startedContact sales