One FastAPI logging config for dev + prod (Uvicorn/Gunicorn)

Stop maintaining separate logging configs for each environment. fapilog auto-detects whether you’re in development or production and adapts formatting, log levels, and color output automatically.

The Problem

A typical project starts with something like this:

# config/logging_dev.py
LOG_LEVEL = "DEBUG"
LOG_FORMAT = "pretty"
LOG_COLORS = True

# config/logging_prod.py
LOG_LEVEL = "INFO"
LOG_FORMAT = "json"
LOG_COLORS = False

These configurations drift apart over time:

  • Someone adds a field to prod logging but forgets dev

  • Color codes in production break JSON parsers

  • Debug logs flood production when someone forgets to change LOG_LEVEL

The Solution: One Config That Adapts

from fastapi import FastAPI
from fapilog.fastapi import FastAPIBuilder

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

This single configuration works everywhere:

  • Local development (uvicorn main:app --reload): Pretty format, DEBUG level, colors enabled

  • Production (Gunicorn workers): JSON format, INFO level, no colors

No environment-specific config files. No conditional imports. Just one line.

What Changes Per Environment

fapilog detects your environment and adjusts defaults accordingly:

Setting

Development (TTY)

Production (no TTY)

Format

Pretty (human-readable)

JSON (machine-parseable)

Level

DEBUG

INFO

Colors

Yes

No

How Detection Works

fapilog uses multiple signals to determine the environment:

  1. TTY detection: Is stdout connected to a terminal?

    • Yes → Development defaults (pretty format, DEBUG)

    • No → Production defaults (JSON format, INFO)

  2. CI detection: Common CI environment variables (CI, GITHUB_ACTIONS, GITLAB_CI, etc.)

    • Present → Forces INFO level, no colors

  3. Container detection: Docker, Kubernetes, Lambda

    • Detected → Production-appropriate defaults

The priority order ensures containers and CI always get production behavior, while local terminals get developer-friendly output.

Running in Different Environments

Local Development (Uvicorn)

uvicorn main:app --reload

Output is pretty-printed with colors:

2026-01-21 10:30:00 INFO  Processing order order_id=12345 request_id=abc-123

Production (Gunicorn)

gunicorn main:app -w 4 -k uvicorn.workers.UvicornWorker

Output is JSON (no colors):

{"timestamp": "2026-01-21T10:30:00.123Z", "level": "INFO", "message": "Processing order", "order_id": "12345", "request_id": "abc-123"}

Docker

CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]

Automatic JSON format—Docker containers don’t have TTYs.

Overriding Defaults

Sometimes you need to force specific behavior regardless of environment.

Environment Variable Overrides

# Force DEBUG in production (temporary debugging)
export FAPILOG_CORE__LOG_LEVEL=DEBUG

# Force JSON in development (testing log aggregation locally)
export FAPILOG_CORE__SINKS='["stdout_json"]'

Programmatic Overrides

from fapilog.fastapi import FastAPIBuilder

# Always use JSON, even in development
app = FastAPI(
    lifespan=FastAPIBuilder()
        .with_preset("fastapi")
        .add_stdout_json()
        .build()
)

# Always DEBUG, even in production (not recommended)
app = FastAPI(
    lifespan=FastAPIBuilder()
        .with_preset("fastapi")
        .with_level("DEBUG")
        .build()
)

Testing JSON Output Locally

To verify your JSON output before deploying:

# Pipe through jq to validate JSON
uvicorn main:app 2>&1 | jq .

Or force JSON format in code:

from fapilog import get_async_logger

async def main():
    logger = await get_async_logger(format="json")
    await logger.info("Testing JSON output")

Complete Example

from fastapi import FastAPI, Depends
from fapilog.fastapi import FastAPIBuilder, get_request_logger

# Works everywhere - adapts to environment automatically
app = FastAPI(
    lifespan=FastAPIBuilder()
        .with_preset("fastapi")
        .build()
)

@app.get("/api/orders/{order_id}")
async def get_order(order_id: str, logger=Depends(get_request_logger)):
    await logger.info("Processing order", order_id=order_id)
    return {"order_id": order_id, "status": "shipped"}

if __name__ == "__main__":
    import uvicorn
    uvicorn.run(app, host="0.0.0.0", port=8000)

Run locally:

python main.py  # Pretty output, DEBUG level

Run in production:

gunicorn main:app -w 4 -k uvicorn.workers.UvicornWorker  # JSON output, INFO level

Going Deeper