structlog JSON Logging in Django

A Django project that adopts structlog naively ends up with two log formats in one stream: your own calls emit clean JSON while django.request tracebacks, django.server lines and every third-party library still print Django's plain text, so no parser can read the stream and no request id ties the two halves together. This page is for backend engineers and SREs running Django 4.2 or 5.x who need a single, flat, machine-parseable JSON stream covering both halves, with a correlation id on every line. It belongs to the standard library versus third-party logging guide inside the Modern Python Logging Libraries Deep Dive, and the whole trick 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 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 replace that and ignore it, or you lose Django's request errors and SQL warnings from your JSON stream. The robust pattern below keeps the stdlib logger tree intact and inserts structlog only as the rendering layer, so every record — yours or Django's — exits as one flat JSON object. That 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.

The dictConfig-to-structlog bridge in a Django project Records from django.request and third-party libraries enter the ProcessorFormatter through its foreign_pre_chain, which replays the shared processors on them. Native structlog events arrive already processed, having ended their chain at wrap_for_formatter. Both lanes meet in one ProcessorFormatter that strips structlog's internal metadata, renders JSON, and writes one flat line per record to stdout. 1 · foreign stdlib records django.request third-party libraries foreign_pre_chain replays the chain 2 · native structlog events app loggers structlog.get_logger shared chain wrap_for_formatter one console handler ProcessorFormatter remove_processors_meta JSONRenderer stdout flat JSON one line Both lanes run the same shared_processors, so every line carries the same request_id.
Django's stdlib records reach parity through foreign_pre_chain; structlog events arrive pre-processed. One formatter renders both.

Prerequisites

Pin both packages. ProcessorFormatter, wrap_for_formatter and remove_processors_meta are stable across structlog 24 and 25, but bounded ranges keep a surprise major release from silently changing the rendering layer of every service.

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

No environment variables are strictly required. Two are worth wiring in, because they let you flip verbosity and human-readable rendering without a code change:

export DJANGO_LOG_LEVEL=INFO      # root + django logger level
export DJANGO_LOG_FORMAT=json     # "console" for a coloured dev renderer

Everything below lives in settings.py plus one middleware module. No AppConfig.ready() hook, no signal, no logging call at import time in your own packages.

Implementation

Configuration runs exactly once because settings.py is imported once during startup, before the WSGI application is built and therefore before any logger is used.

1. Define the shared processor chain and configure structlog. Place this at the bottom of settings.py, after DEBUG is defined. The chain ends with wrap_for_formatter, which hands the final rendering step to the ProcessorFormatter in your dictConfig instead of rendering inside structlog. The processors before it are the ones you want applied to every record, whichever half of the system produced it — this is the same layered chain described in structlog architecture and setup.

# settings.py (bottom)
import os
import structlog

# Processors shared between structlog events and foreign stdlib records.
shared_processors = [
    structlog.contextvars.merge_contextvars,      # inject per-request context
    structlog.stdlib.add_logger_name,             # -> "logger": "django.request"
    structlog.stdlib.add_log_level,               # -> "level": "info"
    structlog.processors.TimeStamper(fmt="iso"),  # -> "timestamp": ISO-8601 UTC
    structlog.processors.StackInfoRenderer(),     # stack_info=True support
    structlog.processors.format_exc_info,         # exc_info -> "exception" string
]

structlog.configure(
    processors=shared_processors + [
        # Prepare the event dict for the stdlib ProcessorFormatter.
        structlog.stdlib.ProcessorFormatter.wrap_for_formatter,
    ],
    logger_factory=structlog.stdlib.LoggerFactory(),   # emit through stdlib handlers
    wrapper_class=structlog.stdlib.BoundLogger,        # .info()/.warning() API
    cache_logger_on_first_use=True,                    # freeze the chain per logger
)
What each processor adds to the event dict A single log.info call starts with an event and an order_id. The shared processors merge the contextvars request_id, path and method, then add logger, level, timestamp and any exception. wrap_for_formatter stops rendering inside structlog and passes the dict on a LogRecord under the keys _from_structlog and _record. The ProcessorFormatter strips that metadata and the JSONRenderer emits one flat line containing every accumulated key. processor stage the event dict at that point log.info("order_fetched") order_id="ORD-55" {"event": "order_fetched", "order_id": "ORD-55"} shared_processors merge_contextvars · add_logger_name add_log_level · TimeStamper(iso) StackInfoRenderer · format_exc_info + "request_id", "path", "method" + "logger", "level", "timestamp" + "exception" when exc_info is set wrap_for_formatter structlog stops rendering here dict rides on the LogRecord as _from_structlog / _record ProcessorFormatter remove_processors_meta JSONRenderer {"request_id": "req-7c2", "logger": "orders", "level": "info", "timestamp": "…", "event": "order_fetched", "order_id": "ORD-55"} Every stage above wrap_for_formatter also runs on foreign records — that is what foreign_pre_chain replays.
One call, one dict: each processor adds keys, and only the formatter turns the accumulated dict into a line.

2. 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 never passed through structlog — Django's own loggers, the ORM, and any third-party package using logging.getLogger(__name__).

# settings.py
LOG_LEVEL = os.getenv("DJANGO_LOG_LEVEL", "INFO")

renderer = (
    structlog.processors.JSONRenderer()
    if os.getenv("DJANGO_LOG_FORMAT", "json") == "json"
    else structlog.dev.ConsoleRenderer()          # readable local development
)

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,
                renderer,                             # final flat JSON
            ],
        },
    },
    "handlers": {
        "console": {
            "class": "logging.StreamHandler",
            "formatter": "json",
        },
    },
    "root": {
        "handlers": ["console"],
        "level": LOG_LEVEL,
    },
    "loggers": {
        "django": {"handlers": ["console"], "level": LOG_LEVEL, "propagate": False},
        "django.request": {"handlers": ["console"], "level": "WARNING", "propagate": False},
        "django.db.backends": {"handlers": ["console"], "level": "WARNING", "propagate": False},
    },
}

Four details here are load-bearing. The "()" key tells dictConfig to instantiate ProcessorFormatter as a callable rather than look up a named format string, which is how foreign_pre_chain and processors reach the constructor. foreign_pre_chain is the bridge for foreign records: a LogRecord produced by django.request 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. Each named logger sets propagate: False and attaches console directly, which prevents a record being handled once at the named logger and again at root — the most common source of duplicated JSON lines in Django. And swapping only the terminal renderer keeps development and production on one chain, so a field that exists locally exists in production; the levels themselves follow the ordinary log level and severity mapping rules, with django.db.backends held at WARNING so SQL stays out of production while retaining the same JSON shape if you raise it during a debugging session.

3. Bind request context with middleware. Clear and bind context variables at the start of each request so merge_contextvars injects a correlation id into every subsequent line without any function passing it around.

# 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()     # never inherit a stale id
        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         # echo for the caller
        return response

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

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

The clear_contextvars() call is not optional. Both WSGI worker threads and asyncio tasks are reused across requests, so without the reset a request that never sets request_id inherits whatever the previous occupant of that thread left behind — a stale id is far worse than a missing one, because it silently merges two unrelated requests in your log search. Because merge_contextvars runs in both the structlog chain and the formatter's foreign_pre_chain, the request id is injected whether a line came 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; under async views each coroutine inherits an isolated copy of the context, the same isolation property covered in using contextvars for request tracing. The one gap is a ThreadPoolExecutor or sync_to_async(thread_sensitive=False) call, whose worker starts with an empty context unless you copy it in explicitly.

With this in place, a view logs structured events without threading a request id through its signature, and Django's own loggers render in the same 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; cached loggers and the contextvars context are per process and per request, so there is no cross-worker bleed — a much simpler story than the queue-based fan-in that thread-safe logging in multiprocessing requires when workers share a file sink.

The trap is Gunicorn's own access and error logs, which it writes through its internal gunicorn.access and gunicorn.error loggers using its own format, bypassing your dictConfig entirely. Clear Gunicorn's default handlers so those loggers propagate into Django's root and render as JSON too.

# 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, JSON lines from different workers interleave on stdout; that is harmless, because each line is a complete independently parseable object and request_id keeps one request's lines correlatable regardless of interleaving. Writing to stdout and letting the platform collect it is also what keeps this configuration free of file handles — if you must write files instead, the trade-offs are in log rotation best practices, and if the sink is slow or remote, put it behind a QueueHandler rather than blocking the worker thread.

One configuration per worker, one interleaved stdout stream The Gunicorn master loads the application once and then forks workers. Because settings.py is imported inside each worker, structlog.configure runs per worker and each keeps its own contextvars, so no context leaks between processes. Emptying logconfig_dict lets gunicorn.access and gunicorn.error propagate into the same dictConfig. All processes write to one stdout stream where lines from different workers interleave, each line still a complete JSON object keyed by request_id. gunicorn master process loads the app once, then forks workers gunicorn.access · .error logconfig_dict = {} fork worker 1 settings.py imported structlog.configure() own contextvars worker 2 settings.py imported structlog.configure() own contextvars worker 3 settings.py imported structlog.configure() own contextvars worker 4 settings.py imported structlog.configure() own contextvars one stdout stream — every line a complete JSON object w1 w3 w2 w4 w1 w2 w4 w3
Each worker configures structlog for itself after the fork; the lines interleave on stdout, and request_id — not order — is what correlates them.

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
propagate each named logger False when it owns a handler prevents duplicated lines

The two entries most often got wrong are the pair at the top and bottom of the table: disable_existing_loggers decides whether foreign loggers exist at all, and propagate decides how many times each surviving record is written. Everything else is about the shape of a line, not whether it appears.

Which setting changes whether a line appears, and which only changes its shape Left column: disable_existing_loggers left at the dictConfig default of True detaches Django's loggers so its own logs stay plain text, and propagate left True on a logger that owns a handler writes every record twice. Right column: a missing foreign_pre_chain leaves foreign records without the shared keys, a missing wrap_for_formatter stops the event dict reaching the renderer, a missing remove_processors_meta leaks the internal _record and _from_structlog keys, and disabling cache_logger_on_first_use rebuilds the chain on every call. changes WHETHER a line appears changes only HOW the line looks disable_existing_loggers left at the dictConfig default True → Django's own logs stay plain text propagate True on a logger that owns a handler → every record is written twice check these two first they decide whether a record exists and how many times it is written foreign_pre_chain → foreign records lose the shared keys wrap_for_formatter → the event dict never reaches the renderer remove_processors_meta → _record and _from_structlog leak in cache_logger_on_first_use → the chain is rebuilt on every call
Two settings decide whether a record reaches the stream at all; the rest only decide what the line looks like once it does.

Verification

Start the development server and hit a view with an explicit correlation id. Both your application event and Django's request logs 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}

Three properties confirm the bridge end to end: every line parses as one flat JSON object with no nesting; the logger field shows both a Django logger (django.request) and an application logger (orders); and request_id is identical across them. If you want this assertion in CI rather than by eye, capture the stream and check the invariant directly:

# tests/test_logging.py
import json
import structlog
from django.test import Client

def test_every_line_is_json_with_a_request_id(capsys):
    Client().get("/orders/ORD-55", headers={"X-Request-ID": "req-test"})
    lines = [l for l in capsys.readouterr().err.splitlines() if l.strip()]
    events = [json.loads(l) for l in lines]          # fails loudly on plain text
    assert {e["request_id"] for e in events} == {"req-test"}
    assert {"orders", "django.request"} <= {e["logger"] for e in events}
The console stream before and after the bridge The left panel shows the naive result: Django's plain-text warning, the development server's access line and a bare JSON event from the application, with nothing to correlate them. The right panel shows the same request after the ProcessorFormatter bridge: three flat JSON objects whose request_id is identical and whose logger field distinguishes django.request, the application logger and django.db.backends. before — two formats, no id after — one JSON stream WARNING django.request: Not Found: /orders/ORD-9 [19/Jun/2026 10:02:44] "GET /orders/ORD-55" 200 {"event": "order_fetched", "order_id": "ORD-55"} INFO django.db.backends: (0.008) SELECT ... {"request_id":"req-7c2","logger":"django.request"} {"request_id":"req-7c2","logger":"orders"} {"request_id":"req-7c2","logger":"django.db.backends"} no correlation id, nothing parses same request_id, different logger
The invariant to assert in CI: one shape for every line, one request_id per request, and the logger field naming both halves of the system.

To confirm the foreign path specifically, raise django.db.backends to DEBUG for one request: the SQL lines must arrive in the same JSON shape, carrying the same request_id, because they travel the foreign_pre_chain. Once that holds, adding a trace_id alongside the request id is a one-processor change — see adding trace IDs to log records and, for the span side of the same request, instrumenting Django with OpenTelemetry.

Common mistakes

  • Error signature: Django's startup and request logs stay plain text while only your views emit JSON. Root cause: disable_existing_loggers was left at its dictConfig default of True, detaching Django's already-created loggers from your handler. Remediation: set disable_existing_loggers: False and give the root logger the console handler, so any propagating record reaches the JSON formatter even if it has no named entry.

  • Error signature: every Django log line appears twice in the stream. Root cause: a named logger such as django.request owns the console handler while also propagating to a root logger with 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 handler-less loggers propagate to root. The same doubling appears under Gunicorn when its default handlers stay installed alongside your dictConfig; clear them with logconfig_dict = {}.

  • Error signature: _record and _from_structlog keys leak into the JSON, or the formatter raises a KeyError. Root cause: ProcessorFormatter.remove_processors_meta is missing from the formatter's processors list, so structlog's internal metadata reaches the renderer. Remediation: make remove_processors_meta the first formatter processor, ahead of JSONRenderer.

  • Error signature: request_id is absent from lines emitted by management commands, or worse, a request shows an id belonging to a previous one. Root cause: the binding happens only in middleware, so non-request entry points have no context; the stale-id variant means clear_contextvars() is missing and a reused worker thread inherited old state. Remediation: keep clear_contextvars() as the first statement of the middleware, and in management commands bind an explicit command and job id at entry — the same discipline applied when migrating an existing codebase to structlog.

From symptom to remedy in a Django JSON stream Four branches from one root. Django's own logs staying plain text means disable_existing_loggers is still True, so set it to False and give the root logger the console handler. Every line appearing twice means a named logger both owns a handler and propagates, so set propagate to False on it. Internal _record keys in the output mean remove_processors_meta is missing from the formatter's processors list and must come first. A missing or stale request_id means clear_contextvars is not the first statement of the middleware. the JSON stream looks wrong which symptom do you see? Django's own logs stay plain text every line appears twice _record keys leak into the JSON request_id missing or stale disable_existing_loggers = False, and give root the console handler propagate: False on every logger that owns a handler remove_processors_meta first in the formatter's processors list clear_contextvars() as the first line of the middleware
Each symptom in the stream maps to exactly one setting — three of them in the LOGGING dict, one in the middleware.

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.