Back to blog
By Rasul

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:

  1. 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.
  2. 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:

  1. Success path: the committed state transition emits one INFO event.
  2. Failure path: the owning boundary emits one ERROR event with exception information.
  3. Correlation path: request and worker records share the explicit correlation ID.
  4. 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_loggers is False unless 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.