structlog JSON Logging in Django

The exact problem this page solves: making a Django project emit one consistent stream of flat JSON logs, covering both your own structlog calls and Django's standard library loggers, with a request id bound to every line. It is part of Python Standard Library vs Third-Party and the broader Modern Python Logging Libraries Deep Dive. The key is bridging Django's dictConfig-driven logging into structlog's processor pipeline.

Django is an instructive case because it commits hard to the standard library: its settings expose a LOGGING dictionary that is passed straight to logging.config.dictConfig, and framework components such as django.request, django.server, and django.db.backends all log through named stdlib loggers. You cannot simply replace that with structlog and ignore it, or you will lose Django's request errors and SQL warnings from your JSON stream. The robust pattern, shown below, keeps the stdlib tree intact and inserts structlog only as the rendering layer, so every record, whether it came from your code or from Django itself, exits as one flat JSON object. This mirrors the broader decision discussed in structlog vs Loguru vs Standard Library Logging: use structlog as the front end while the standard library keeps catching everything else.

Django plus structlog unified rendering Django loggers and application structlog loggers both feed a stdlib handler whose formatter is structlog ProcessorFormatter, which applies the shared chain and emits flat JSON. django.request stdlib record app logger structlog event ProcessorFormatter shared chain JSON line flat output
Django stdlib records and structlog events converge on one ProcessorFormatter that emits flat JSON.

Prerequisites

pip install "django>=4.2,<6.0" "structlog>=24.1.0,<26.0.0"

No extra environment variables are required, though a DJANGO_LOG_LEVEL toggle is convenient:

export DJANGO_LOG_LEVEL=INFO

Implementation

The work happens entirely in settings.py plus one middleware class. Configuration runs once because settings.py is imported once during startup.

  1. Define the shared processor chain and configure structlog. Place this at the bottom of settings.py. The chain ends with wrap_for_formatter so the final rendering is delegated to the ProcessorFormatter in your dictConfig.
# settings.py (bottom)
import structlog

# Processors shared between structlog events and foreign stdlib records.
shared_processors = [
    structlog.contextvars.merge_contextvars,
    structlog.stdlib.add_logger_name,
    structlog.stdlib.add_log_level,
    structlog.processors.TimeStamper(fmt="iso"),
    structlog.processors.StackInfoRenderer(),
    structlog.processors.format_exc_info,
]

structlog.configure(
    processors=shared_processors + [
        # Prepare the event dict for the stdlib ProcessorFormatter.
        structlog.stdlib.ProcessorFormatter.wrap_for_formatter,
    ],
    logger_factory=structlog.stdlib.LoggerFactory(),
    wrapper_class=structlog.stdlib.BoundLogger,
    cache_logger_on_first_use=True,
)
  1. Wire Django's LOGGING dictConfig to a ProcessorFormatter. This is the bridge. The json formatter is a ProcessorFormatter; its foreign_pre_chain runs the shared processors on records that did not originate from structlog (Django's own loggers, the ORM, third-party packages).
# settings.py
LOGGING = {
    "version": 1,
    "disable_existing_loggers": False,
    "formatters": {
        "json": {
            "()": structlog.stdlib.ProcessorFormatter,
            "foreign_pre_chain": shared_processors,   # for non-structlog records
            "processors": [
                structlog.stdlib.ProcessorFormatter.remove_processors_meta,
                structlog.processors.JSONRenderer(),  # final flat JSON
            ],
        },
    },
    "handlers": {
        "console": {
            "class": "logging.StreamHandler",
            "formatter": "json",
        },
    },
    "root": {
        "handlers": ["console"],
        "level": "INFO",
    },
    "loggers": {
        "django": {"handlers": ["console"], "level": "INFO", "propagate": False},
        "django.request": {"handlers": ["console"], "level": "WARNING", "propagate": False},
        "django.db.backends": {"handlers": ["console"], "level": "WARNING", "propagate": False},
    },
}

Three details in this dictConfig are load-bearing. First, the "()" key tells dictConfig to instantiate ProcessorFormatter as a callable rather than look up a named class, which is how you pass foreign_pre_chain and processors as constructor arguments. Second, foreign_pre_chain is the bridge for foreign records: a LogRecord produced by django.request or django.db.backends never passed through the structlog chain, so the formatter replays shared_processors on it to reach parity with native structlog events before the renderer runs. Third, each named logger sets propagate: False and attaches console directly; this prevents a record from being handled once at the named logger and again at root, which is the most common source of duplicated JSON lines in Django. The django.db.backends logger only emits at DEBUG, so leaving it at WARNING keeps SQL out of production while preserving the same JSON shape if you raise it during debugging.

  1. Bind request context with middleware. Clear and bind contextvars per request so merge_contextvars injects a correlation id into every line. Register the class in MIDDLEWARE.
# observability/middleware.py
import uuid
import structlog

logger = structlog.get_logger("django.request")

class RequestContextMiddleware:
    def __init__(self, get_response):
        self.get_response = get_response

    def __call__(self, request):
        structlog.contextvars.clear_contextvars()
        request_id = request.headers.get("X-Request-ID", str(uuid.uuid4()))
        structlog.contextvars.bind_contextvars(
            request_id=request_id,
            path=request.path,
            method=request.method,
        )
        logger.info("request_started")
        response = self.get_response(request)
        logger.info("request_finished", status_code=response.status_code)
        response["X-Request-ID"] = request_id
        return response

Add it near the top of the middleware stack so the context is bound before any view runs:

# settings.py
MIDDLEWARE = [
    "observability.middleware.RequestContextMiddleware",
    "django.middleware.security.SecurityMiddleware",
    # ... the rest of Django's default middleware ...
]

A note on ordering: because merge_contextvars runs in both the structlog chain and the formatter's foreign_pre_chain, the request id is injected regardless of whether a line originates from your code or from a Django internal logger, as long as the middleware bound it earlier in the same request. Synchronous Django runs each request in its own thread, and contextvars is correct across threads, so no thread-local plumbing is needed. If you adopt Django's async views, the same contextvars approach continues to work because each coroutine inherits an isolated copy of the context, which is the same isolation property that makes the pattern reliable under concurrency.

With this in place, a view can log structured events without manually threading a request id, and Django's own loggers render in the same JSON shape:

# views.py
import structlog
from django.http import JsonResponse

log = structlog.get_logger("orders")

def get_order(request, order_id: str):
    log.info("order_fetched", order_id=order_id)   # request_id auto-merged
    return JsonResponse({"order_id": order_id})

Running under Gunicorn

In production Django runs behind a WSGI server, and Gunicorn is the common choice. Two things matter for log integrity. Gunicorn forks worker processes after loading the application, and because settings.py is imported inside each worker, structlog.configure runs once per worker with no extra wiring; the cached loggers and the contextvars context are per process and per request, so there is no cross-worker bleed. The trap is Gunicorn's own access and error logs, which it writes through its internal gunicorn.access and gunicorn.error stdlib loggers using its own format, bypassing your dictConfig entirely. Disable Gunicorn's default handlers and let those loggers propagate into Django's root so they too render as JSON.

# gunicorn.conf.py
# Route Gunicorn's own loggers through Django's dictConfig instead of its defaults.
logconfig_dict = {}            # do not let Gunicorn install its own handlers
accesslog = "-"                 # access log to stdout
errorlog = "-"                  # error log to stdout
capture_output = True           # send worker stdout/stderr to the error stream
disable_redirect_access_to_syslog = True
gunicorn myproject.wsgi:application -c gunicorn.conf.py --workers 4

Expected Output:

{"request_id": "req-7c2", "path": "/orders/ORD-55", "method": "GET", "logger": "django.request", "level": "info", "timestamp": "2026-06-19T10:02:44.118Z", "event": "request_started"}

With capture_output on, anything a library writes to raw stdout still lands on the same stream as the JSON, so a stray print is visible rather than swallowed. Because each worker is a separate process, the JSON lines from different workers interleave on stdout; that is harmless because each line is a complete, independently parseable object and the request_id keeps a single request's lines correlatable regardless of interleaving.

Configuration options

Option Where Recommended value Effect
disable_existing_loggers LOGGING False keeps Django and library loggers alive
foreign_pre_chain json formatter shared_processors renders non-structlog records as JSON
wrap_for_formatter last structlog processor required hands rendering to the formatter
remove_processors_meta formatter processors first strips internal keys before JSON
wrapper_class structlog.configure structlog.stdlib.BoundLogger stdlib-compatible bound logger
cache_logger_on_first_use structlog.configure True avoids per-call chain rebuild

Verification

Start the development server and hit a view. Both your application event and Django's request log should appear as flat JSON sharing the same request_id.

curl -H "X-Request-ID: req-7c2" localhost:8000/orders/ORD-55

Expected Output:

{"path": "/orders/ORD-55", "method": "GET", "request_id": "req-7c2", "logger": "django.request", "level": "info", "timestamp": "2026-06-19T10:02:44.118Z", "event": "request_started"}
{"path": "/orders/ORD-55", "method": "GET", "request_id": "req-7c2", "order_id": "ORD-55", "logger": "orders", "level": "info", "timestamp": "2026-06-19T10:02:44.121Z", "event": "order_fetched"}
{"path": "/orders/ORD-55", "method": "GET", "request_id": "req-7c2", "logger": "django.request", "level": "info", "timestamp": "2026-06-19T10:02:44.123Z", "event": "request_finished", "status_code": 200}

The shared request_id across the Django logger (django.request) and your application logger (orders) confirms the bridge is working end to end.

Common mistakes

Error signature: Django startup logs appear as plain text, only your views are JSON. Root cause: disable_existing_loggers left at its default or set to True, detaching Django's loggers from your handler. Remediation: set disable_existing_loggers: False and ensure the root logger has the console handler so propagating records reach the JSON formatter.

Error signature: request_id missing from log lines outside the request cycle. Root cause: the binding only happens in middleware, so management commands and startup code have no context. Remediation: that is expected; bind context explicitly in those entry points, or accept that pre-request lines simply omit the field rather than carrying a stale one.

Error signature: KeyError or internal _record/_from_structlog keys leaking into JSON. Root cause: omitting ProcessorFormatter.remove_processors_meta from the formatter's processors list. Remediation: make remove_processors_meta the first formatter processor so internal metadata is stripped before JSONRenderer runs.

Error signature: every Django log line appears twice in the JSON stream. Root cause: a named logger such as django.request is attached to console while also propagating to a root logger that has the same handler, so one record is emitted at both levels. Remediation: set propagate: False on every named logger that carries its own handler, and let only loggers without an explicit handler propagate up to root. The same doubling appears under Gunicorn when its default handlers are left installed alongside your dictConfig; clear them with logconfig_dict = {} so Gunicorn does not add a second sink.

Frequently Asked Questions

How do I make Django's own logs render as JSON?

Point Django's LOGGING dictConfig formatter at structlog.stdlib.ProcessorFormatter with a foreign_pre_chain, so records from django.request and django.server flow through the same JSON renderer as your structlog calls.

Where should I call structlog.configure in a Django project?

Call it at the bottom of settings.py, after defining LOGGING. settings.py is imported once during startup, which guarantees configuration happens exactly once before any logger is used.

How do I attach a request id to every Django log line?

Add a middleware that calls structlog.contextvars.clear_contextvars then bind_contextvars with a request id at the start of each request. The merge_contextvars processor then injects it into every event.

Do third-party libraries that use standard logging still get JSON output?

Yes, as long as their loggers propagate to a handler whose formatter is the structlog ProcessorFormatter. The foreign_pre_chain renders foreign LogRecord objects through the same processors.

Does this configuration survive Gunicorn's worker forks?

Yes. settings.py runs in each worker after fork, so structlog.configure executes per worker and contextvars stay isolated per request. Set Gunicorn's capture-output so worker stdout reaches the same JSON stream.