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.
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
)
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.
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.
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}
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_loggerswas left at itsdictConfigdefault ofTrue, detaching Django's already-created loggers from your handler. Remediation: setdisable_existing_loggers: Falseand give therootlogger theconsolehandler, 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.requestowns theconsolehandler while also propagating to arootlogger with the same handler, so one record is emitted at both levels. Remediation: setpropagate: Falseon every named logger that carries its own handler and let only handler-less loggers propagate toroot. The same doubling appears under Gunicorn when its default handlers stay installed alongside yourdictConfig; clear them withlogconfig_dict = {}. -
Error signature:
_recordand_from_structlogkeys leak into the JSON, or the formatter raises aKeyError. Root cause:ProcessorFormatter.remove_processors_metais missing from the formatter'sprocessorslist, so structlog's internal metadata reaches the renderer. Remediation: makeremove_processors_metathe first formatter processor, ahead ofJSONRenderer. -
Error signature:
request_idis 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 meansclear_contextvars()is missing and a reused worker thread inherited old state. Remediation: keepclear_contextvars()as the first statement of the middleware, and in management commands bind an explicitcommandand job id at entry — the same discipline applied when migrating an existing codebase to structlog.
LOGGING dict, one in the middleware.Related
- Standard library vs third-party logging — the parent guide on when to keep the stdlib tree and only replace the rendering layer.
- structlog architecture and setup — how the processor chain, wrapper class and logger factory fit together beyond Django.
- Migrating from standard logging to structlog — the incremental path for an existing codebase full of
logging.getLoggercalls. - Configuring logging with dictConfig — the schema Django's
LOGGINGsetting is passed to, including the"()"factory key used above. - How to configure Python logging for production — levels, volume and sink choices once the JSON shape is settled.
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.