Logging request/response bodies without hanging (safe patterns)

Want to log request bodies for debugging API issues? Here’s how to do it without breaking your app.

The Problem: Why Body Logging Breaks

Reading request bodies in middleware seems straightforward—but it has hidden pitfalls that cause hangs, broken routes, and streaming failures.

Don’t Do This

from fastapi import FastAPI

app = FastAPI()


@app.middleware("http")
async def log_body(request, call_next):
    # This will break your app
    body = await request.body()
    print(f"Request body: {body}")
    response = await call_next(request)
    return response

This pattern causes several problems:

  1. Body consumed twicerequest.body() reads the stream. Your route handler tries to read it again and gets empty bytes or hangs waiting for data that already arrived.

  2. Memory exhaustion — Large file uploads or payloads get loaded entirely into memory before your route even runs.

  3. Event loop blocking — Synchronous print() blocks the event loop. With high traffic, your app becomes unresponsive.

Common Symptoms

If you’ve added body logging and see these issues, this is likely the cause:

  • Routes hang indefinitely

  • 422 Unprocessable Entity on valid JSON

  • Empty request.json() in route handlers

  • Streaming uploads fail or timeout

  • Memory usage spikes under load

The Solution: Cache the Body

The key is reading the body once and making it available to both your middleware and route handler. Starlette provides receive caching for this.

Safe Body Logging Middleware

from fastapi import FastAPI, Request
from starlette.middleware.base import BaseHTTPMiddleware
from fapilog.fastapi import FastAPIBuilder

app = FastAPI(
    lifespan=FastAPIBuilder()
        .with_preset("fastapi")
        .build()
)


class BodyLoggingMiddleware(BaseHTTPMiddleware):
    """Safely log request bodies without consuming them."""

    MAX_BODY_LOG = 10_000  # Truncate bodies larger than 10KB

    async def dispatch(self, request: Request, call_next):
        # Cache the body so it can be read multiple times
        body = await request.body()

        # Access the logger from app state
        logger = request.app.state.fapilog_logger

        # Log truncated body
        body_preview = body[: self.MAX_BODY_LOG]
        if len(body) > self.MAX_BODY_LOG:
            body_preview = body_preview + b"...[truncated]"

        await logger.debug(
            "request_body",
            path=request.url.path,
            method=request.method,
            body=body_preview.decode("utf-8", errors="replace"),
            body_size=len(body),
            truncated=len(body) > self.MAX_BODY_LOG,
        )

        response = await call_next(request)
        return response


# Add after FastAPIBuilder configures the app
app.add_middleware(BodyLoggingMiddleware)

This works because:

  • BaseHTTPMiddleware automatically caches the body when you call request.body()

  • Subsequent calls (including in your route) return the cached bytes

  • We truncate before logging to avoid memory issues

  • fapilog’s async logger doesn’t block the event loop

Response Body Logging

Logging response bodies is trickier—you need to intercept the streaming response. Here’s a safe pattern:

from starlette.responses import Response
from starlette.types import Message


class ResponseBodyMiddleware(BaseHTTPMiddleware):
    """Log response bodies with size limits."""

    MAX_RESPONSE_LOG = 10_000

    async def dispatch(self, request: Request, call_next):
        response = await call_next(request)

        # Only log for JSON responses under size limit
        content_type = response.headers.get("content-type", "")
        if "application/json" not in content_type:
            return response

        # Collect response body chunks
        body_chunks = []
        total_size = 0

        async def receive_body(message: Message):
            nonlocal total_size
            if message["type"] == "http.response.body":
                chunk = message.get("body", b"")
                if total_size < self.MAX_RESPONSE_LOG:
                    body_chunks.append(chunk)
                total_size += len(chunk)

        # This requires a custom response wrapper—see full example below
        # For simplicity, log from route handlers instead

        return response

For most use cases, logging response bodies from route handlers is simpler and safer than middleware interception.

Truncation for Large Bodies

Always limit how much body data you log. The examples above use a fixed byte limit, but fapilog’s size_guard processor provides automatic truncation for all log payloads:

from fapilog import LoggerBuilder

logger = await (
    LoggerBuilder()
    .with_size_guard(max_bytes="100 KB", action="truncate")
    .build_async()
)

With size_guard enabled:

  • Payloads exceeding max_bytes are automatically truncated

  • A _truncated: true field marks truncated entries

  • Critical fields like message are preserved

What Truncated Output Looks Like

{
  "message": "request_body",
  "body": "{ \"user\": \"alice\", \"data\": \"...[truncated]",
  "body_size": 150000,
  "_truncated": true,
  "path": "/api/upload"
}

Adjusting Truncation Limits

# More aggressive truncation for high-volume endpoints
logger = await (
    LoggerBuilder()
    .with_size_guard(
        max_bytes="10 KB",
        action="truncate",
        preserve_fields=["correlation_id", "path", "method"],
    )
    .build_async()
)

Bodies Are Redacted Too

Request and response bodies pass through the same redaction pipeline as all log fields. Sensitive data in JSON bodies is automatically masked.

Redacted Body Example

With field-based redaction enabled:

from fapilog import LoggerBuilder

logger = await (
    LoggerBuilder()
    .with_field_mask(fields=["password", "credit_card", "ssn"])
    .build_async()
)

# Request body: {"username": "alice", "password": "hunter2"}
await logger.info(
    "login_request",
    body={"username": "alice", "password": "hunter2"},
)

# Log output: password is masked
# {"message": "login_request", "body": {"username": "alice", "password": "***"}}

JSON Body Redaction

When logging parsed JSON bodies, pass them as dictionaries rather than strings to enable deep redaction:

# Good: Dict enables field-level redaction
body_dict = await request.json()
await logger.debug("request_body", body=body_dict)

# Less effective: String only gets pattern matching
body_str = (await request.body()).decode()
await logger.debug("request_body", body=body_str)

Summary

Problem

Solution

Body consumed twice

Use BaseHTTPMiddleware (caches automatically)

Memory exhaustion

Truncate before logging

Event loop blocking

Use fapilog’s async logger

Sensitive data in bodies

Enable redaction, pass dicts not strings

Going Deeper