Filter concepts by levelShowing all levels.

Django · Structured Logging

Structured Logging

Four parts, four gates, records instead of sentences — and secrets that never get in

  • A record passes four gateslogger level, logger filters, handler level, handler filters — a missing line is one of them
  • propagate: False on any logger you give handlersotherwise root emits it again and every line appears twice
  • Stable message, variable fieldsinterpolation destroys grouping, counts and alerts
  • Allow-list, never block-listcard_number is caught; cardNumber, pan and otp are not
  • Tracebacks carry what your logs avoided@sensitive_variables and @sensitive_post_parameters close that path

1.Loggers, handlers, formatters, filters — and levels

Django's `LOGGING` setting has four kinds of moving part, and knowing which one to reach for removes most of the confusion. A **logger** is the named bucket you write to — `logging.getLogger(__name__)`. A **handler** decides where a record goes: the console, a file, an email. A **formatter** turns the record into text. A **filter** decides whether a record continues, and can also add fields to it. Records travel up the name hierarchy, so `orders.services` also reaches `orders` and the root — which is why one misplaced handler can double every line you emit.

Key features

  • Four components: a logger is "a named bucket to which messages can be written", a handler "determines what happens to each message", a filter gives "additional control over which log records are passed from logger to handler", and a formatter describes "the exact format of that text"
  • Levels: DEBUG (low-level detail), INFO (general), WARNING (a minor problem), ERROR (a major problem), CRITICAL (a critical problem)
  • A record is filtered at four points: the logger's level, the logger's filters, each handler's level, and each handler's filters
  • Records propagate up the dotted name hierarchy; `propagate: False` stops it, and its absence is the usual cause of duplicate lines
  • Django merges your `LOGGING` with its defaults — `disable_existing_loggers: True` is how people accidentally silence `django.request`
  • Filters can *enrich*, not only reject: returning `True` after setting an attribute is how a request id reaches every record

Django's own loggers, and what each gives you free

Django's own loggers, and what each gives you free
LoggerEmitsAt
`django.request`5XX responsesERROR (4XX at WARNING), with `status_code` and `request`
`django.server`runserver request handlingERROR for 5XX, WARNING for 4XX, INFO otherwise
`django.db.backends`every application SQL statementDEBUG, with `duration`, `sql`, `params`, `alias`
`django.security.*``SuspiciousOperation` and security errorsWARNING, or ERROR if it reaches the WSGI handler
One logger entry, and what each key decides

"django.request": {"handlers": ["console", "errors"], "level": "WARNING", "propagate": False}

"django.request"

the bucket, by dotted name — Django writes here already: 5XX responses at ERROR and 4XX at WARNING, with `status_code` and the `request` attached to the record. You are configuring an existing logger, not creating one.

"handlers"

where records go from here — A list, because one record can go to several destinations. Each handler then applies its own level and its own filters, independently of the ones below.

["console", "errors"]

the destinations by name — These must exist in the `handlers` dict. A typo here is silent — the record is simply never delivered anywhere.

"level": "WARNING"

the first of two gates — Records below this never leave the logger. A handler set to DEBUG cannot recover them, which is why "I set the handler to DEBUG and see nothing" is so common.

"propagate": False

stop climbing the hierarchy — Without this the record is also offered to the root logger, so a root handler emits it a second time. This single key is the usual cause of every line appearing twice.

  • Whole: "django.request": {"handlers": ["console", "errors"], "level": "WARNING", "propagate": False}
  • "django.request" — the bucket, by dotted name: Django writes here already: 5XX responses at ERROR and 4XX at WARNING, with `status_code` and the `request` attached to the record. You are configuring an existing logger, not creating one.
  • "handlers" — where records go from here: A list, because one record can go to several destinations. Each handler then applies its own level and its own filters, independently of the ones below.
  • ["console", "errors"] — the destinations by name: These must exist in the `handlers` dict. A typo here is silent — the record is simply never delivered anywhere.
  • "level": "WARNING" — the first of two gates: Records below this never leave the logger. A handler set to DEBUG cannot recover them, which is why "I set the handler to DEBUG and see nothing" is so common.
  • "propagate": False — stop climbing the hierarchy: Without this the record is also offered to the root logger, so a root handler emits it a second time. This single key is the usual cause of every line appearing twice.

Command

python
log = logging.getLogger(__name__)      # "orders.services" — reaches "orders" and root
log.warning("stock low", extra={"sku": sku, "remaining": remaining})

Four parts and four gates: a record passes the logger's level, the logger's filters, each handler's level, and each handler's filters — so a missing line is usually one of those, not a broken config. Records climb the dotted hierarchy, so attach handlers in one place (root) and set `propagate: False` on any logger you do give handlers to, or every line appears twice. Keep `disable_existing_loggers: False` so Django's own `django.request` survives, use filters to *add* fields rather than only to reject, and remember `django.db.backends` logs SQL only when `DEBUG` is `True`.

2.JSON logs, and the context fields worth carrying

A structured log line is a JSON object rather than a sentence, so every value is a field you can search, filter and aggregate on. `log.info("order settled", extra={"order_id": 8412, "amount": "49.00"})` produces a record whose fields a log system can index — instead of a string somebody has to write a regular expression against later. Three fields are worth carrying on nearly everything: the `request_id` that ties the record to the rest of the request, a trace id if you emit traces, and a user id where it is appropriate to store one.

Key features

  • Pass context through `extra={...}`, which puts the keys on the record for the formatter to render
  • Keep the message a short, stable label — variable data goes in fields, or the message stops being groupable
  • Reserved `LogRecord` attribute names (`message`, `asctime`, `name`, `levelname`, `args`, …) cannot be used as `extra` keys
  • Django already attaches `status_code` and `request` to `django.request` records, and `duration`/`sql`/`params`/`alias` to `django.db.backends`
  • Use one spelling per concept across the codebase — `order_id` everywhere, not `orderId` in half of it
  • A user id makes a log line personal data: prefer the surrogate key to an email, and know your retention

The fields worth carrying, and what each one joins to

The fields worth carrying, and what each one joins to
FieldJoins the record toNotes
`request_id`every other record from the same request, and the traceinjected by a filter, never passed by hand
`trace_id` / `span_id`the trace in your tracing backendonly if you emit traces
`user_id`a personsurrogate key, not an email — this is personal data
`tenant_id`a customerthe field that makes "is it only them?" answerable in one query
`route`the endpointthe URL pattern, not the path
domain ids (`order_id`, `sku`)a row you can go and readconsistent names across the whole codebase
The same event, written two ways — and the questions each one can answer

A sentence

  • Readable in a console, and nowhere else
  • Every value is embedded in the text
  • Searching means regular expressions over prose
  • Reword the message and every saved search breaks
  • "All declines over £100 for tenant 42, last week" is impractical

A record

  • A stable message plus typed fields
  • Every value is indexed and filterable
  • Searching is a field query
  • The message is a groupable key you can count and alert on
  • That question is one filter expression
  • A sentence
    • Readable in a console, and nowhere else
    • Every value is embedded in the text
    • Searching means regular expressions over prose
    • Reword the message and every saved search breaks
    • "All declines over £100 for tenant 42, last week" is impractical
  • A record
    • A stable message plus typed fields
    • Every value is indexed and filterable
    • Searching is a field query
    • The message is a groupable key you can count and alert on
    • That question is one filter expression

Command

python
log.info("order settled", extra={"order_id": order.id, "amount": str(order.total)})

Write records, not sentences: a short stable message plus typed fields in `extra`. Stability is what makes the message a groupable key — interpolate a value into it and you lose counts, rates and alerts, exactly as with error grouping. Carry the joining fields on everything (`request_id`, a trace id, `tenant_id`, and a `user_id` where appropriate), keep one spelling per concept across the codebase, and let an allow-list decide what a formatter emits. Never log a whole model or request: name the fields, or the line's contents become a function of the entire codebase.

3.Keeping secrets out of logs

The roadmap lists what must never reach a log: passwords, access tokens, refresh tokens, API secrets, payment credentials, and unnecessary personal information. Logs are the least protected copy of your data — they are shipped to third parties, kept for months, and readable by everyone on the team — so a secret in a log is a secret that has leaked. Build the defence into the pipeline rather than into people's memory: an allow-list in the formatter, a redacting filter for what does get through, and Django's `sensitive_variables` / `sensitive_post_parameters` so tracebacks do not carry the values instead.

Key features

  • Never log: passwords, access tokens, refresh tokens, API secrets, payment credentials, unnecessary personal data
  • Prefer an **allow-list** in the formatter — a block-list only excludes the field names somebody thought of
  • `@sensitive_variables()` hides local variables from error reports; `@sensitive_post_parameters()` hides POST values
  • Django's error reporting filters `settings` that look secret, but nothing filters your own log calls
  • Redact in the pipeline, not at call sites: the leak is always the call nobody remembered
  • A logged secret is a leaked secret — rotate it; you cannot know who read the log or where it was copied

Where each leak comes from, and what stops it

Where each leak comes from, and what stops it
PathExampleDefence
a deliberate log call`log.info("login", extra={"password": pw})`allow-list in the formatter
a whole object`extra={"request": request.POST}`allow-list, plus never logging containers
a value inside an allowed fielda token in a URL in `message`a redacting filter over the rendered text
an unhandled exceptionlocals and POST in the traceback`sensitive_variables` / `sensitive_post_parameters`
a third-party librarya client logging its own request headersset that logger's level, or filter it
SQL loggingquery `params` containing personal data`django.db.backends` is DEBUG-only — keep `DEBUG` off
Three layers, because one is always bypassed

At the call site

Name fields, never dump objects

extra={"user_id": u.id}, not u.__dict__

Log which fields changed

the names carry the audit value, the values carry the risk

Depends on memory

so it fails exactly where nobody was thinking

In the pipeline

Allow-list in the formatter

a field appears only if someone named it

Redacting filter over the text

catches tokens embedded in URLs and messages

One place to review

the whole policy is two objects, not every call site

In error reporting

@sensitive_variables()

locals are hidden from the traceback

@sensitive_post_parameters()

POST values are hidden from the report

Crashes carry everything

the password your logs avoided is in the traceback

  • At the call site — necessary, and never sufficient
    • Name fields, never dump objects — extra={"user_id": u.id}, not u.__dict__
    • Log which fields changed — the names carry the audit value, the values carry the risk
    • Depends on memory — so it fails exactly where nobody was thinking
  • In the pipeline — structural — cannot be forgotten
    • Allow-list in the formatter — a field appears only if someone named it
    • Redacting filter over the text — catches tokens embedded in URLs and messages
    • One place to review — the whole policy is two objects, not every call site
  • In error reporting — the path people forget
    • @sensitive_variables() — locals are hidden from the traceback
    • @sensitive_post_parameters() — POST values are hidden from the report
    • Crashes carry everything — the password your logs avoided is in the traceback

Command

python
@sensitive_variables("password", "token")          # keep locals out of tracebacks
@sensitive_post_parameters("password", "card_number")

Logs are the least protected, longest-lived copy of your data, so a logged secret is a leaked secret — rotate it rather than deleting the line, because you cannot know who read it or where it was forwarded. Make redaction structural: an allow-list in the formatter so a field appears only if someone named it (a block-list fails open on every name nobody thought of), plus a redacting filter for tokens embedded in otherwise-fine text. And close the path neither layer sees with `@sensitive_variables()` and `@sensitive_post_parameters()`, or the password your log calls avoided arrives in the traceback instead.

Remember

  • Keep `disable_existing_loggers: False`, or you silently lose `django.request` — the logger that reports every 5XX.
  • `django.db.backends` logs SQL only when `DEBUG` is `True`, "regardless of the logging level or handlers that are installed".
  • Filters can enrich as well as reject: returning `True` after setting an attribute is how a request id reaches every record.
  • A logged secret is a leaked secret — rotate it. Deleting the line does not recall the copies already shipped.

Pro tip

  • Use `json.dumps(..., default=str)` in a formatter. A formatter that raises on a Decimal turns a logging call into an application error.
  • Log which fields changed, not their values — the audit question is almost always "what changed", and the names carry none of the risk.

Next up

  • Health Checks
  • Email Systems
  • Internationalization and Time Zones

Sources

  • Logging topic guide — docs.djangoproject.com/en/5.2/topics/logging/
  • Logging reference — docs.djangoproject.com/en/5.2/ref/logging/
  • Error reporting — docs.djangoproject.com/en/5.2/howto/error-reporting/
  • Every claim checked against Django 5.2 LTS docs · checked 2026-09-05

Spotted a mistake on this page? Tell us.