Django Logging for Production
Configure Django logging with JSON events, request and task context, safe fields, error routing, and tests for production SaaS systems.
Production Django logging should emit structured events to standard output, bind a small allow-list of request, task, and domain identifiers, and route the same records to your log and error backends. Keep user-visible state in the database. Test the event contract. Do not log credentials, session IDs, raw payloads, or personal data by default.
The LOGGING dictionary is only the transport layer. The harder work is
deciding what one record means, which fields stay stable, and how an operation
can be followed after it leaves the HTTP request. This guide builds that
contract, configures JSON output, carries correlation into a worker, separates
logs from application state, and verifies the result with pytest.
The framework and library behavior here was verified against Django 6.0, Python, structlog, OpenTelemetry, OWASP, and pytest documentation on August 1, 2026.
On this page: event contract · configuration · request context · background tasks · state boundaries · sensitive data · routing · tests · checklist
Start with an event contract, not a LOGGING dictionary
Django uses Python's standard logging
module. Loggers create
records, handlers send them somewhere, filters accept or enrich them, and
formatters turn them into text or JSON. Those components cannot rescue an
event called Something went wrong with no object, outcome, or correlation
field.
For a production SaaS, define one event envelope before choosing a renderer:
| Dimension | Example | Why it stays stable |
|---|---|---|
| Event | project_generation_failed |
Names the class of thing that happened |
| Outcome | queued, succeeded, rejected, failed |
Makes success and failure queryable without parsing prose |
| Domain identity | project_id, invoice_id, export_id |
Connects evidence to the object a user sees |
| Correlation identity | request_id, correlation_id, provider_event_id |
Joins execution boundaries |
| Execution identity | task_id, attempt, worker |
Explains retries and concurrent work |
| Error identity | error_code, exception class, stack trace |
Groups failures without treating a message string as a schema |
This six-field envelope is the useful core. Timestamp, severity, logger name, release, and environment can be added by configuration. The application call should supply the domain meaning:
logger.info(
"project_generation_queued",
project_id=project.pk,
outcome="queued",
request_id=request_id,
)
The OpenTelemetry log data model makes a similar separation between a record's event name, severity, body, resource, trace context, and attributes. You do not need to adopt OpenTelemetry to benefit from the model. A stable event name plus typed fields is easier to search, alert on, and migrate than a sentence assembled with string interpolation.
There are two important boundaries:
- Logs explain a transition; the database owns state. If a user needs to know whether an export is queued, ready, or failed, store that state on the export. A log backend is not a transactional product API.
- Correlation must cross boundaries explicitly. A context variable can follow code in one process. It will not appear inside a queue worker or a provider webhook unless you carry or persist an identifier.
Djass applies both rules to repository generation. A project row owns the
queued, generating, ready, or failed state. Structured events explain
what happened around each transition. The project-generation pipeline
documentation shows the
domain lifecycle that remains inspectable even if the log destination changes.
Configure Django logging once
The Django LOGGING configuration
guide uses Python's
dictConfig format and extends the framework defaults. The Django how-to
recommends keeping
disable_existing_loggers set to False; setting it to True can silently
disable existing logger namespaces instead of giving you a clean slate.
The following baseline uses structlog to render application and ordinary standard-library records through one console handler. Development output can stay readable while production emits one JSON object per line.
Install the renderer:
uv add structlog
Then configure it in settings.py:
import logging
import os
import structlog
timestamper = structlog.processors.TimeStamper(fmt="iso")
shared_processors = [
structlog.contextvars.merge_contextvars,
structlog.stdlib.add_logger_name,
structlog.stdlib.add_log_level,
timestamper,
]
structlog.configure(
processors=[
*shared_processors,
structlog.stdlib.ProcessorFormatter.wrap_for_formatter,
],
logger_factory=structlog.stdlib.LoggerFactory(),
cache_logger_on_first_use=True,
)
production = os.environ.get("ENVIRONMENT") == "production"
LOGGING = {
"version": 1,
"disable_existing_loggers": False,
"formatters": {
"structured": {
"()": structlog.stdlib.ProcessorFormatter,
"foreign_pre_chain": [
structlog.stdlib.add_logger_name,
structlog.stdlib.add_log_level,
timestamper,
structlog.stdlib.ExtraAdder(),
],
"processors": [
structlog.stdlib.ProcessorFormatter.remove_processors_meta,
(
structlog.processors.JSONRenderer()
if production
else structlog.dev.ConsoleRenderer()
),
],
},
},
"handlers": {
"console": {
"class": "logging.StreamHandler",
"formatter": "structured",
"stream": "ext://sys.stdout",
},
},
"root": {
"handlers": ["console"],
"level": os.environ.get("DJANGO_LOG_LEVEL", "INFO"),
},
}
ProcessorFormatter
is the bridge: structlog documents it as the formatter that can render both
structlog events and records emitted by Python or Django.
The final handler writes to standard output, which lets a container platform
own collection, retention, and delivery instead of making each web and worker
process rotate local files.
Use module namespaces for ordinary library code:
import logging
logger = logging.getLogger(__name__)
Use a named structured logger when the application needs event fields:
import structlog
logger = structlog.get_logger("myapp.billing").bind(project="myapp")
Pick one convention for first-party application code and keep third-party records compatible through the standard-library bridge. The Djass generator option catalog exposes Sentry and PostHog without requiring application code to invent a separate logging API for each destination.
Bind request context without logging the request
Request context should answer “which execution produced this event?” It should not serialize the request object. A safe default is a generated request ID, HTTP method, and a stable internal user ID when your privacy policy allows it.
For a synchronous middleware stack, place this middleware after Django's
AuthenticationMiddleware so request.user is available:
from uuid import uuid4
from structlog.contextvars import (
bind_contextvars,
clear_contextvars,
)
def logging_context(get_response):
def middleware(request):
clear_contextvars()
request_id = uuid4().hex
user_id = None
if request.user.is_authenticated:
user_id = request.user.pk
bind_contextvars(
request_id=request_id,
method=request.method,
user_id=user_id,
)
try:
response = get_response(request)
response["X-Request-ID"] = request_id
return response
finally:
clear_contextvars()
return middleware
Generate the identifier inside the application unless a trusted proxy already owns and validates it. An arbitrary client header should not be allowed to inject an unbounded value into every record.
Python context variables support threaded and asynchronous execution, and structlog provides context-local binding helpers that add them to each event. Its documentation also warns that sync and async contexts can be isolated in hybrid applications. If your middleware stack crosses that boundary, add an integration test that proves the ID survives the exact deployment path you run.
Do not bind an email address, access token, session key, complete URL, query string, or request body as ambient context. Ambient fields appear on every event and multiply the cost of one privacy mistake.
Carry correlation into background tasks
A queue starts a new execution context. Pass a correlation ID and domain ID in the task payload or persist them on the domain object; never assume the request's context variables cross the broker.
from django_q.tasks import async_task
async_task(
"reports.tasks.build_export",
export.pk,
correlation_id=request_id,
task_name="Build export",
)
logger.info(
"export_queued",
export_id=export.pk,
outcome="queued",
correlation_id=request_id,
)
Bind fresh worker context at the task entrypoint and clear it when the attempt ends:
import structlog
from structlog.contextvars import bind_contextvars, clear_contextvars
logger = structlog.get_logger("myapp.exports")
def build_export(export_id, correlation_id):
clear_contextvars()
bind_contextvars(
export_id=export_id,
correlation_id=correlation_id,
execution="worker",
)
try:
logger.info("export_started", outcome="processing")
export = render_export(export_id)
logger.info(
"export_completed",
outcome="ready",
bytes=export.size,
)
except Exception:
logger.exception("export_failed", outcome="failed")
raise
finally:
clear_contextvars()
The Django Q2 operations guide covers the worker process and retry configuration used by generated projects. The related background task decision guide explains when work belongs in that queue at all.
Use logger.exception()
inside an exception handler when the stack trace is needed. Python's logging
API adds exception information for that call. Log the failure once at the
boundary that owns the outcome, then re-raise or convert it according to the
task contract. Logging the same exception in every helper creates several
records for one failure without adding evidence.
Log state transitions at the owning boundary
A reliable record says what this boundary can prove. The browser knows a click happened. Django knows a validated database transition committed. A worker knows an attempt started or completed. A verified webhook knows what the provider reported.
For one export, the chain could look like this:
| Boundary | Event | Required fields |
|---|---|---|
| Request | export_queued |
export_id, user_id, request_id, outcome=queued |
| Worker | export_started |
export_id, correlation_id, task_id, attempt |
| Worker | export_completed |
export_id, correlation_id, duration_ms, bytes, outcome=ready |
| Worker | export_failed |
export_id, correlation_id, attempt, error_code, exception |
| Download | export_downloaded |
export_id, user_id, request_id, outcome=served |
The database still owns Export.status. That makes polling, support, retries,
and API responses independent of a vendor's log retention period. A log query
then explains why the state changed or failed to change.
Provider webhooks need the same discipline. Log the provider's event ID, event type, verified outcome, and your domain object ID. Do not log the full body. If a retry delivers the same event again, the provider ID and your idempotency record should explain the duplicate without requiring payload inspection.
This boundary rule also keeps logs distinct from product analytics. The Django analytics with PostHog guide assigns browser intent, committed Django state, and verified provider outcomes to different event owners. Product analytics answers funnel questions; operational logs explain execution. They may share stable identifiers, but they should not become one undifferentiated event stream.
Protect secrets and personal data
Treat log fields as an allow-list. OWASP's logging cheat sheet recommends enough context to answer when, where, who, and what, while advising against directly recording access tokens, session identifiers, passwords, database connection strings, encryption keys, and sensitive personal data.
A practical allow-list for many Django SaaS events is:
- internal numeric or random object IDs;
- a generated request or correlation ID;
- a provider event ID that is not a credential;
- an operation name and bounded outcome;
- an error code or exception class;
- duration, attempt count, byte count, and HTTP status;
- environment and release added by configuration.
Values that need special scrutiny include email addresses, IP addresses, file paths, URLs, user agents, model representations, and arbitrary exception text. They can carry personal data or embed secrets from an upstream response.
Avoid these patterns:
# Credentials and payloads can escape into the log backend.
logger.warning("Invalid key", api_key=raw_key)
logger.info("Webhook received", payload=request.body)
# Model reprs and form errors may contain more than the event needs.
logger.error("Save failed", user=user, cleaned_data=form.cleaned_data)
Log bounded evidence instead:
logger.warning(
"api_authentication_rejected",
outcome="rejected",
credential_present=bool(raw_key),
request_id=request_id,
)
logger.info(
"webhook_accepted",
provider="stripe",
provider_event_id=event["id"],
provider_event_type=event["type"],
outcome="verified",
)
Django's own logging security reference warns that request records and stack traces may contain sensitive information. Local variables and settings are particularly risky when a handler or error backend attaches them automatically. Review the privacy defaults of every destination and keep secret values out of the record before routing starts.
The environment-variable
reference shows where generated
projects keep service configuration. Knowing where a secret is loaded is also
where you should verify that it never enters extra, context bindings, error
messages, or model repr output.
Route severity without creating duplicate events
Use severity to describe operational impact, not to rank how interesting a developer finds the code path:
| Level | Use it for |
|---|---|
DEBUG |
Local diagnostic detail that is normally disabled in production |
INFO |
Expected business or lifecycle transitions |
WARNING |
A rejected, degraded, retried, or suspicious path that the system handled |
ERROR |
One operation failed and needs investigation or recovery |
CRITICAL |
The service or a required subsystem cannot continue safely |
Django's default console
configuration
does not show records below WARNING. Set the production level deliberately
and keep it configurable. Raising the threshold is safer than removing useful
calls from code during an incident.
Python loggers are
hierarchical. A
record emitted by myapp.billing.webhooks can propagate through
myapp.billing, myapp, and the root logger. Attaching handlers at more than
one point in that chain can emit the same record multiple times. Prefer one
handler high in the tree, then use named logger levels and filters to control
noisy dependencies.
Route the same accepted record to the destinations you need:
- standard output for the deployment collector;
- Sentry for exceptions and selected high-severity records;
- a centralized log backend for search, retention, and alerts;
- PostHog Logs when you intentionally use it for operational records.
Do not both log an exception and manually capture the same exception unless the integration requires a distinct second event. Verify the path with one failure in staging and count what arrives. The Django Sentry production guide defines the release, privacy, sampling, worker, ownership, and incident-closure contract around those error events. Djass's generator options let you include Sentry and PostHog scaffolding; choose only the destinations you will operate and test.
Test Django logging as a contract
Logging tests should assert the event name, important fields, level, and negative privacy conditions. Snapshotting an entire rendered JSON line makes tests brittle because timestamps, process IDs, and formatter details change.
pytest's caplog
fixture exposes captured
LogRecord objects and can set the capture level for a named logger:
import logging
import pytest
def test_export_failure_emits_safe_event(caplog, export):
caplog.set_level(logging.ERROR, logger="myapp.exports")
with pytest.raises(StorageUnavailable):
build_export(export.pk, correlation_id="corr-test-1")
record = next(
item
for item in caplog.records
if "export_failed" in item.getMessage()
)
assert record.levelno == logging.ERROR
assert "corr-test-1" in caplog.text
assert str(export.pk) in caplog.text
assert "secret-storage-token" not in caplog.text
The exact field access depends on the structured renderer and pytest bridge you choose. The contract does not: one failure event appears, it carries the domain and correlation IDs, and a known secret does not.
Add four focused tests:
- Success path: the committed state transition emits one
INFOevent. - Failure path: the owning boundary emits one
ERRORevent with exception information. - Correlation path: request and worker records share the explicit correlation ID.
- Privacy path: credentials and representative personal fields are absent from rendered output.
Then run one staging drill. Trigger a known failure, find it from the domain object ID, follow it across the request and worker, confirm the error backend contains one issue, and verify the user-facing state remains correct.
Common Django logging mistakes
Treating prose as a schema
logger.info(f"Generated project {project.id}") is readable but forces every
query and alert to parse a sentence. Keep the message as a stable event name
and put the identifier in a field.
Logging every function entry and exit
This creates volume without explaining a business transition. Log at ownership boundaries: accepted, queued, started, completed, rejected, retried, and failed.
Passing whole objects in extra fields
Objects change their string representation and may expose hidden fields. Pass the minimum stable identifier and bounded metadata.
Assuming a request ID crosses a queue
Context-local state ends with the execution context. Pass or persist a correlation ID when enqueueing work, then bind it again in the worker.
Using logs as the only failure record
If a user needs to see the failure or retry it, persist domain state and a safe diagnostic code. Log retention and indexing should not define product behavior. When a security-sensitive action also needs durable actor, target, outcome, and denial evidence, use the Django audit log design guide to define that separate record and its transaction policy.
Adding handlers to every namespace
Logger propagation can send the same record to parent handlers. Configure one transport path, then narrow levels and filters for specific namespaces.
Production Django logging checklist
Before shipping:
- [ ] Every important operation has stable event names and bounded outcomes.
- [ ] Domain, correlation, and execution IDs can join request and worker work.
- [ ] User-visible state is persisted outside the log backend.
- [ ] Production emits structured records to standard output.
- [ ]
disable_existing_loggersisFalseunless disabling is deliberate. - [ ] Handler placement and propagation produce one record per event.
- [ ] Secrets, session IDs, payloads, and personal data are excluded by default.
- [ ] Exception routing produces one error issue, not duplicates.
- [ ] Success, failure, correlation, and privacy tests pass.
- [ ] A staging drill proves the record can be found from the domain object.
- [ ] Routine probe traffic is suppressed without hiding dependency failures; use the Django health check contract to separate readiness, worker freshness, and alerting.
Djass-generated repositories can include Sentry, PostHog, Q2, environment configuration, and the surrounding SaaS structure from one option catalog. Review the available generator modules and current Djass pricing when you want that operational foundation in the repository before product-specific work begins.
Django logging FAQ
What is Django's default logging level?
Django's default console output shows WARNING and higher. Lower-level records
such as INFO and DEBUG require an explicit logger or handler level. Check
both levels: a record must pass the logger threshold and the handler threshold
before it is emitted.
Should Django write logs to a file in production?
In a container deployment, write structured records to standard output and let the platform collect, rotate, retain, and forward them. A local file can still fit a traditional server, but every web and worker process then needs a shared collection and rotation plan.
How do I output JSON logs from Django?
Attach a JSON formatter to a standard StreamHandler in LOGGING. structlog's
ProcessorFormatter can render both structured application events and normal
Python/Django records through that handler, which avoids maintaining two
separate pipelines.
Should I log Django request bodies?
Not by default. Request bodies can contain passwords, tokens, payment details, personal data, and large untrusted input. Log an allow-list of bounded fields such as method, route class, status, request ID, domain ID, and payload size.
How do I test Django logging?
Use pytest's caplog or Python's assertLogs to capture records. Assert the
event name, level, domain and correlation fields, and the absence of a known
secret. Keep formatter timestamps and process metadata out of exact snapshots.