Logging & Error Reporting

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

The reference material consulted while preparing these pages is listed in the section bibliography.

This page is written against Django 6.1 (6.1.1, released 2026-09-02; 6.1.2 is scheduled for 2026-10-06), Python 3.12-3.14 and PostgreSQL 15 or newer, with sentry-sdk 2.71.0 (2026-09-28), all checked on 2026-10-03; where the 5.2 LTS (5.2.17) behaves differently the text says so. Every example was run on that stack in a scratch project on SQLite with the Django test client, the runserver development server and uvicorn; none of them needs PostgreSQL or Redis, and no example talks to a real mail server or to Sentry (mail goes to the in-memory test outbox or the console backend, Sentry events are captured by a local transport function). When the site is public, DEBUG is False, nobody sees tracebacks in the browser and the only way to learn that the Bookshelf catalogue is broken is for the application to say so: through log lines that an operator can search, through an email to the people on call, and through an error tracker that groups the same failure into one issue. This page covers the Python logging model as Django configures it, the default configuration and the loggers Django writes to, how to configure your own with LOGGING, structured (JSON) logging with a request id on every line, the built-in error emails and the broken-link report, how to keep passwords and card numbers out of every report, Sentry, and how to test and deploy all of it.

In a hurry: what goes where

Need Rule of thumb Section

Emit a message from your code

logger = logging.getLogger(__name__) at module level, then logger.warning("…​ %s", value); never print(), never log calls in settings.py.

Making a logging call

Know what Django already does

DEBUG=True: django logger at INFO to the console. DEBUG=False: ERROR and above emailed to ADMINS. django.server always logs requests to the console.

What Django does by default

Change where records go

One LOGGING dictionary in settings.py with "version": 1 and "disable_existing_loggers": False.

Configuring logging with LOGGING

Silence or redirect one noisy logger

A logger entry with a NullHandler or a higher level and "propagate": False.

Django’s loggers

Machine-readable logs

A JSON Formatter, extra={…​} for fields, a filter that adds a request id from a contextvars.ContextVar.

Structured logging

Get told about 500 errors

Set ADMINS, SERVER_EMAIL and a mail configuration (MAILERS since 6.1); leave AdminEmailHandler in the default logging.

Server errors by email: ADMINS and AdminEmailHandler

Get told about broken links

BrokenLinkEmailsMiddleware first in MIDDLEWARE, MANAGERS, and IGNORABLE_404_URLS for the noise.

Broken links: BrokenLinkEmailsMiddleware, MANAGERS and IGNORABLE_404_URLS

Keep secrets out of reports

@sensitive_variables, @sensitive_post_parameters, a SafeExceptionReporterFilter subclass for site-wide rules, and short reports to trusted people only.

Filtering sensitive data out of reports

Track errors across servers

Sentry (or another tracker): sentry_sdk.init() with the DSN from the environment, send_default_pii=False, a before_send scrubber.

Sentry

Test it

assertLogs / assertNoLogs, the mail.outbox, a stand-in transport for Sentry.

Testing logs and error reports

Run it in production

Log to stdout/stderr, let the platform collect the stream, keep levels in environment variables.

Running it in production

Making a logging call

Python’s standard logging module is the only logging API Django uses and the only one you need to call. The convention is one logger per module, named after the module, created at import time:

Logging from a view (catalog/views.py)
import logging

from django.http import Http404, HttpResponse

logger = logging.getLogger(__name__)  # "catalog.views"


def book_detail(request, pk):
    if pk > 100:
        logger.warning("Book %s not found (asked by %s)", pk, request.GET.get("who", "anonymous"))
        raise Http404
    logger.info("Showing book", extra={"book_id": pk})
    return HttpResponse(f"book {pk}")

Four habits make that code worth having:

  • Pass arguments, do not format. logger.warning("Book %s not found", pk) formats the message only when a handler actually emits the record; an f-string pays the formatting cost even for records that are thrown away, and it makes every message unique, which defeats grouping in a log search or an error tracker.

  • Pick the level for the reader. Python’s levels are DEBUG (low-level diagnostics), INFO (normal events), WARNING (something unexpected but handled), ERROR (an operation failed) and CRITICAL (the process cannot continue). A logger discards records below its level; a handler discards records below its own.

  • Use logger.exception() inside except blocks. It logs at ERROR and attaches the traceback; logger.error(…​, exc_info=True) is the same thing. Log the failure once where it is handled, not at every layer it passes through.

  • Never call the logger in settings.py. Django applies LOGGING as part of django.setup(), after the settings module is read, so records emitted while settings are loading are handled by whatever Python defaults exist at that moment. Try logging from a view, a management command or an AppConfig.ready().

Every record emitted by that logger flows through a small, fixed pipeline. Understanding it is the key to every configuration in this page.

flowchart LR call["logger.warning(...)
creates a LogRecord"] --> lvl{"record level >=
logger level?"} lvl -- no --> drop1["discarded"] lvl -- yes --> lf["logger filters"] lf --> h["each handler on this logger
(level check, handler filters)"] h --> fmt["formatter renders the record as text"] fmt --> dest["stream, file, socket,
email, queue"] lf --> prop{"propagate?"} prop -- "yes" --> parent["parent logger's handlers
(not its level or filters)"] prop -- "no" --> stop["stop here"]

A logger is a named bucket; names are dotted, so catalog.views is a child of catalog, which is a child of the root logger. A handler decides what happens to a record (write it, send it); a filter vetoes or edits records on a logger or a handler; a formatter turns a LogRecord into the final text. The detail that surprises people: when a record propagates to a parent, the parent’s handlers see it, but the parent’s level is not consulted, only the levels of its handlers.

What Django does by default

Django ships a complete logging configuration, available as django.utils.log.DEFAULT_LOGGING, and merges your LOGGING into it (when you keep disable_existing_loggers set to False). Its behaviour is:

Condition Result

DEBUG is True

The django logger sends records of INFO and above (except django.server) to the console handler.

DEBUG is False

The django logger sends ERROR and CRITICAL records (except django.server) to AdminEmailHandler, which emails ADMINS.

Always

django.server (the runserver request line) logs INFO and above to the console and does not propagate.

Everything else

Python’s own defaults: WARNING and above from any other logger go to standard error through the last-resort handler.

The default configuration is a dictionary with three handlers (console, django.server, mail_admins), two filters (require_debug_true, require_debug_false), one formatter (django.server) and two loggers (django, django.server). You can look at it in a shell:

Inspecting the default configuration
from django.utils.log import DEFAULT_LOGGING

print(sorted(DEFAULT_LOGGING["handlers"]))
print(sorted(DEFAULT_LOGGING["loggers"]))
print(DEFAULT_LOGGING["loggers"]["django"]["level"])

Output on Django 6.1.1 (run through the test runner of the scratch project):

['console', 'django.server', 'mail_admins']
['django', 'django.server']
INFO

Two practical consequences. First, in production (DEBUG=False) an INFO message from your own code is not printed unless you configure it, because your catalog.views logger has no handler of its own and only inherits Python’s WARNING default; this is the usual reason "my log lines disappear in production". Second, the django.server logger is for the development server only: under Gunicorn or Uvicorn the request lines come from the server’s own access log, see Running it in production.

Configuring logging with LOGGING

LOGGING is a dictionary in the logging.config.dictConfig() schema. Django passes it to LOGGING_CONFIG (by default logging.config.dictConfig) while setting up. The four sections are the four parts of the model: formatters, filters, handlers and loggers, plus an optional root logger.

The Bookshelf configuration

The Bookshelf wants: readable lines with a request id in development, JSON in production, redaction of e-mail addresses and tokens that sneak into messages, ERROR emails to ADMINS only in production, and levels controlled from the environment. The helpers it uses (RequestIdFilter, RedactFilter, JsonFormatter) are written in Structured logging and Redacting your own messages.

The Bookshelf LOGGING setting (config/settings.py)
import os

LOG_LEVEL = os.environ.get("DJANGO_LOG_LEVEL", "INFO")

LOGGING = {
    "version": 1,
    "disable_existing_loggers": False,
    "filters": {
        "request_id": {"()": "catalog.log.RequestIdFilter"},
        "redact": {"()": "catalog.log.RedactFilter"},
        "require_debug_false": {"()": "django.utils.log.RequireDebugFalse"},
    },
    "formatters": {
        "plain": {
            "format": "{asctime} {levelname} [{request_id}] {name}: {message}",
            "style": "{",
        },
        "json": {"()": "catalog.log.JsonFormatter"},
    },
    "handlers": {
        "console": {
            "class": "logging.StreamHandler",
            "filters": ["request_id", "redact"],
            "formatter": "json" if os.environ.get("LOG_JSON") == "1" else "plain",
        },
        "mail_admins": {
            "level": "ERROR",
            "class": "django.utils.log.AdminEmailHandler",
            "filters": ["require_debug_false"],
            "include_html": False,
        },
    },
    "loggers": {
        "django": {"handlers": ["console", "mail_admins"], "level": LOG_LEVEL, "propagate": False},
        "catalog": {"handlers": ["console"], "level": LOG_LEVEL, "propagate": False},
    },
}

Running the scratch project with runserver and four requests (the second one was /books/700/?who=reader@example.com) produced these lines, with the traceback of the 500 shortened:

2026-10-02 20:00:11,160 INFO [demo-1] catalog.views: Showing book
2026-10-02 20:00:11,161 INFO [-] django.server: "GET /books/7/ HTTP/1.1" 200 6
2026-10-02 20:00:11,167 WARNING [d4d9c4b5b6b8] catalog.views: Book 700 not found (asked by <email>)
2026-10-02 20:00:11,168 WARNING [d4d9c4b5b6b8] django.request: Not Found: /books/700/
2026-10-02 20:00:11,168 WARNING [-] django.server: "GET /books/700/?who=<email> HTTP/1.1" 404 179
2026-10-02 20:00:11,174 ERROR [595f34e5ca50] django.request: Internal Server Error: /boom/
Traceback (most recent call last):
  …
RuntimeError: catalogue index is corrupted
2026-10-02 20:00:11,204 ERROR [b18ac4bcba7d] django.security.DisallowedHost: Invalid HTTP_HOST header: 'evil.test'. …

The request id comes from Structured logging; the django.server lines show [-] because the object attached to those records is a socket, not a Django request. The redaction filter masked the address in both the message and the logged query string. The mechanics worth knowing:

  • The "()" key names a factory (a class or a function by dotted path); the remaining keys of that dictionary are passed to it as keyword arguments. Without "()", a filter or formatter dictionary uses the standard class and a handler dictionary needs "class".

  • "style": "{" chooses str.format() placeholders in a formatter; the default style is %. Pick one and use it everywhere.

  • "propagate": False on django stops Django records at that logger. Leave it out (default True) when you want them to reach a root handler too, and remember that propagation hands the record to the parents' handlers without re-checking their levels.

  • Levels live on loggers and on handlers. The console handler above has no level, so it prints everything its loggers let through; mail_admins only accepts ERROR and above.

  • The disable_existing_loggers pitfall. If the key is missing, dictConfig treats it as True and silently disables every logger that already exists and is not named in the dictionary: they still exist but discard everything, without propagating. The scratch project proved it: a module logger created before the call reported disabled as True after a configuration without the key, and False after the configuration above. Always write "disable_existing_loggers": False.

Levels from the environment

The Django documentation suggests reading the level from an environment variable so that development and staging can be verbose without a code change. The configuration above does exactly that with DJANGO_LOG_LEVEL and also lets DJANGO_LOG_LEVEL=DEBUG show Django’s own debug logging, which is very verbose because it includes every SQL query (only when DEBUG is True, see Django’s loggers).

Namespacing, hierarchy and propagation

getLogger(__name__) in catalog/views.py creates catalog.views; a loggers entry named catalog therefore captures catalog.views, catalog.tasks and every other module of the app, which is why the Bookshelf configures catalog once instead of one entry per module. A more specific entry overrides its parent for that subtree:

Quieter private module (settings.py fragment)
"loggers": {
    "catalog": {"handlers": ["console"], "level": "INFO", "propagate": False},
    "catalog.views.private": {"handlers": ["console"], "level": "WARNING", "propagate": False},
},

LOGGING_CONFIG: other configuration mechanisms

LOGGING_CONFIG is the dotted path of the callable that receives LOGGING. Replace it to use a different scheme; set it to None to turn Django’s configuration step off entirely (logging itself keeps working, with Python’s defaults) and do it yourself:

Configuring logging by hand (config/settings_manual.py)
from .settings import *  # noqa: F403

LOGGING_CONFIG = None

import logging.config

logging.config.dictConfig({
    "version": 1,
    "disable_existing_loggers": False,
    "handlers": {"console": {"class": "logging.StreamHandler"}},
    "root": {"handlers": ["console"], "level": "INFO"},
})

With DJANGO_SETTINGS_MODULE=config.settings_manual, manage.py check passes and the django logger has no handlers of its own while the root logger carries the stream handler. The catch from the documentation: Django’s default step runs once settings are fully loaded, but a dictConfig call in the settings file runs immediately, so it must come after every setting it depends on.

Django’s loggers

Django writes to a family of loggers; each can be given its own handler, level or silence.

Logger What it receives

django

The parent of the whole family; Django never logs to it directly.

django.request

One record per 4xx (WARNING) and 5xx (ERROR) response, with status_code and request as extra context. Requests that end in django.security are not repeated here.

django.server

The runserver request lines: INFO, WARNING for 4xx, ERROR for 5xx.

django.template

Template rendering; a missing context variable is a DEBUG message.

django.db.backends

Every application-level SQL statement at DEBUG, with duration, sql, params and alias as extra context. Only enabled when settings.DEBUG is True, whatever the handler levels.

django.db.backends.schema

Schema changes made by migrations (not the SQL inside RunPython).

django.security.*

One sub-logger per SuspiciousOperation subtype (for example django.security.DisallowedHost) and django.security.csrf.

django.utils.autoreload

File-change detection in the development server.

django.contrib.auth, django.contrib.gis, django.contrib.sessions, django.dispatch

ERROR records when a password reset email cannot be sent, when GeoDjango fails, when a cached_db session store has a non-fatal error, and when a signal receiver raises.

The scratch project exercised the ones that matter for a web application. assertLogs records the logger’s output and exposes the extra fields:

The request and security loggers (catalog/tests.py)
from django.test import TestCase


class RequestLogging(TestCase):
    def test_404_is_a_warning_with_status_code(self):
        with self.assertLogs("django.request", "WARNING") as cm:
            response = self.client.get("/books/700/")
        self.assertEqual(response.status_code, 404)
        record = cm.records[0]
        self.assertEqual(record.status_code, 404)
        self.assertEqual(record.request.path, "/books/700/")

    def test_disallowed_host_goes_to_security_logger(self):
        with self.assertLogs("django.security.DisallowedHost", "ERROR"):
            response = self.client.get("/books/1/", headers={"host": "evil.test"})
        self.assertEqual(response.status_code, 400)

The same run confirmed three smaller facts. With DEBUG=True, a block of ORM or cursor work logs each statement (including the savepoints a TestCase uses) to django.db.backends, each record carrying alias, duration, sql and params; with DEBUG=False the same code logged nothing (assertNoLogs passed). A template that renders a missing variable logs Exception while resolving variable 'missing' in template 'unknown'. at DEBUG on django.template. And the default django handlers of the scratch configuration printed the django.request lines shown in The Bookshelf configuration.

Silencing one kind of record

To drop a class of events, give its logger a NullHandler and stop propagation. The documentation’s example silences DisallowedHost records, the typical bot noise that otherwise reaches the admin email:

Silencing DisallowedHost (settings.py fragment)
LOGGING = {
    # ...
    "handlers": {
        "null": {"class": "logging.NullHandler"},
    },
    "loggers": {
        "django.security.DisallowedHost": {
            "handlers": ["null"],
            "propagate": False,
        },
    },
}

Verified in the scratch project: with that dictionary applied, a request with a bad Host header still returned 400 but nothing reached the django logger. Do this deliberately: the 400 is still returned, and you lose the signal that someone is probing the host header.

Handlers, filters and formatters

The Python handlers you will actually use

Django adds one handler (AdminEmailHandler, below) to Python’s own. For the rest:

  • logging.StreamHandler writes to sys.stderr (or a stream you give it): the right default for containers and platforms, see Running it in production.

  • logging.FileHandler writes to a file; logging.handlers.WatchedFileHandler, RotatingFileHandler and TimedRotatingFileHandler reopen or rotate the file. Prefer WatchedFileHandler when logrotate does the rotating, and never use FileHandler with a path the application user cannot write to.

  • logging.handlers.SysLogHandler, HTTPHandler, SocketHandler and SMTPHandler ship records to a collector; each does network I/O inside the logging call, which matters in async code (Sync, async and blocking handlers).

  • logging.NullHandler discards.

  • logging.handlers.QueueHandler hands records to another thread so slow handlers do not block the request (since Python 3.12 it can be configured from dictConfig, see Sync, async and blocking handlers).

Django’s three filters

  • django.utils.log.RequireDebugFalse and RequireDebugTrue pass records only when settings.DEBUG is False (respectively True); the default mail_admins handler uses the first so that development errors are never emailed.

  • django.utils.log.CallbackFilter(callback) calls a function on each record and drops the record when the function returns False. The documentation’s example keeps UnreadablePostError (a client that cancelled an upload) out of the admin emails:

Dropping UnreadablePostError from the admin emails
from django.http import UnreadablePostError


def skip_unreadable_post(record):
    if record.exc_info:
        exc_type, exc_value = record.exc_info[:2]
        if isinstance(exc_value, UnreadablePostError):
            return False
    return True


LOGGING = {
    # ...
    "filters": {
        "skip_unreadable_posts": {
            "()": "django.utils.log.CallbackFilter",
            "callback": skip_unreadable_post,
        },
    },
    "handlers": {
        "mail_admins": {
            "level": "ERROR",
            "filters": ["skip_unreadable_posts"],
            "class": "django.utils.log.AdminEmailHandler",
        },
    },
    # ...
}

Source: CallbackFilter

In the scratch project the filter returned False for a record carrying an UnreadablePostError and True for a RuntimeError. Filters can also edit the record; the RedactFilter below does exactly that. A filter attached to a logger only sees records logged directly to that logger; to cover records that propagate from children, attach it to the handler.

Structured logging

A line such as Book 700 not found is for a person reading a terminal. Log aggregators (Loki, Elasticsearch, CloudWatch, Datadog, any ELK-style stack) are better served by one JSON object per line with stable field names: they can then filter by request_id, book_id or level without regular expressions. Python’s standard library already contains everything needed; three pieces cooperate.

1. A formatter that emits JSON. Attributes passed with extra={…​} become attributes of the LogRecord, so the formatter can copy every non-standard attribute into the payload:

A JSON formatter (catalog/log.py)
import json
import logging

# Attributes every LogRecord has; anything else came from extra={...}
# (django.request adds status_code and the request object, which we skip).
_STANDARD = set(logging.makeLogRecord({}).__dict__) | {"message", "asctime", "request_id", "request"}


class JsonFormatter(logging.Formatter):
    """One JSON object per line, ready for a log shipper."""

    def format(self, record):
        payload = {
            "time": self.formatTime(record, "%Y-%m-%dT%H:%M:%S%z"),
            "level": record.levelname,
            "logger": record.name,
            "message": record.getMessage(),
            "request_id": getattr(record, "request_id", "-"),
        }
        payload.update({k: v for k, v in record.__dict__.items() if k not in _STANDARD})
        if record.exc_info:
            payload["exception"] = self.formatException(record.exc_info)
        return json.dumps(payload, default=str)

2. A request id on every record. A contextvars.ContextVar carries a value for the duration of one request, in threads and in async tasks alike. A middleware sets it (the full pattern is on Middleware; this version also stores the id on the request) and a filter copies it onto every record:

Request id middleware and filter (catalog/middleware.py and catalog/log.py)
# catalog/log.py
import contextvars
import logging

request_id_var = contextvars.ContextVar("request_id", default="-")


class RequestIdFilter(logging.Filter):
    """Stamp every record with the id of the request being served."""

    def filter(self, record):
        request_id = request_id_var.get()
        if request_id == "-":
            # django.request 4xx records are emitted after the middleware chain
            # has unwound, so fall back to the request attached to the record.
            request = getattr(record, "request", None)
            request_id = getattr(request, "request_id", "-")
        record.request_id = request_id
        return True


# catalog/middleware.py
import uuid

from .log import request_id_var


class RequestIdMiddleware:
    def __init__(self, get_response):
        self.get_response = get_response

    def __call__(self, request):
        request_id = request.headers.get("X-Request-ID") or uuid.uuid4().hex[:12]
        request.request_id = request_id
        token = request_id_var.set(request_id)
        try:
            response = self.get_response(request)
        finally:
            request_id_var.reset(token)
        response["X-Request-ID"] = request_id
        return response

Source: contextvars and Middleware

Two things were learned by running it. The first version of the filter only read the context variable, and the django.request 404 line had request_id - while the 500 line had the real id: Django logs a 4xx response from BaseHandler.get_response() after the middleware chain returned, so the variable had been reset. Records from django.request carry the request object as record.request, so the fallback above recovers the id. The second: the same code served by uvicorn (ASGI) produced a correct id on both the view’s own record and the django.request record, because context variables follow the request through async code.

3. Fields through extra. Running the project with LOG_JSON=1 under uvicorn printed:

{"time": "2026-10-02T20:00:15-0500", "level": "INFO", "logger": "catalog.views", "message": "Showing book", "request_id": "asgi-1", "book_id": 7}
{"time": "2026-10-02T20:00:15-0500", "level": "WARNING", "logger": "django.request", "message": "Not Found: /books/700/", "request_id": "5db2596ae1db", "status_code": 404}

Do not use reserved attribute names in extra (message, asctime or any LogRecord field such as name, args, msg): the logging module raises KeyError instead of overwriting them.

Third-party packages build on the same mechanism when you want more than a hand-written formatter: python-json-logger (a JSON Formatter), structlog (processors, bound loggers and context binding) and django-structlog (request middleware for structlog). They were not run for this page; the standard-library recipe above is enough for most projects and keeps the LOGGING dictionary as the single source of truth.

Redacting your own messages

Django’s error reports have their own sensitive-data filtering (Filtering sensitive data out of reports), but nothing filters the text you pass to a logger. A filter that edits the record before it reaches the handler is the last line of defence, not a substitute for not logging secrets in the first place:

A redaction filter (catalog/log.py)
import logging
import re


class RedactFilter(logging.Filter):
    """Mask e-mail addresses and bearer tokens in your own log messages."""

    PATTERNS = [
        (re.compile(r"[\w.+-]+@[\w-]+\.[\w.-]+"), "<email>"),
        (re.compile(r"(?i)bearer\s+\S+"), "Bearer <token>"),
    ]

    def filter(self, record):
        message = record.getMessage()
        for pattern, replacement in self.PATTERNS:
            message = pattern.sub(replacement, message)
        record.msg, record.args = message, None
        return True

A test sends a message containing an address and a bearer token through the three pieces together and parses the output as JSON:

Testing the formatter and the filters (catalog/tests.py)
import io
import json
import logging

from django.test import SimpleTestCase

from catalog.log import JsonFormatter, RedactFilter, RequestIdFilter


class CustomFormatting(SimpleTestCase):
    def test_json_formatter_and_redaction(self):
        stream = io.StringIO()
        handler = logging.StreamHandler(stream)
        handler.setFormatter(JsonFormatter())
        handler.addFilter(RequestIdFilter())
        handler.addFilter(RedactFilter())
        logger = logging.getLogger("catalog.demo")
        logger.addHandler(handler)
        try:
            logger.warning("Reset for %s with Authorization: Bearer %s", "a@b.example", "tok-123", extra={"book_id": 7})
        finally:
            logger.removeHandler(handler)
        entry = json.loads(stream.getvalue().splitlines()[0])
        self.assertEqual(entry["book_id"], 7)
        self.assertNotIn("tok-123", entry["message"])
        self.assertNotIn("a@b.example", entry["message"])

Source: Testing tools

Sync, async and blocking handlers

A logging call is synchronous: logger.info() returns after every handler has emitted the record. A StreamHandler writing to stdout is fast enough to ignore, but a FileHandler on a slow disk, an SMTPHandler or a SocketHandler blocks the thread, and in an async def view that thread is the event loop, so every other request on that worker waits. The AdminEmailHandler is in the same position: it sends mail synchronously, which Django accepts because it only runs on errors.

The standard cure is QueueHandler: the logging call puts the record on a queue and returns; a QueueListener thread feeds the slow handlers. Since Python 3.12 dictConfig can build both, and the same configuration is verified here:

A queued handler in dictConfig (Python 3.12+)
import logging
import logging.config

logging.config.dictConfig({
    "version": 1,
    "disable_existing_loggers": False,
    "handlers": {
        "slow": {"class": "logging.StreamHandler"},  # stands for a file, socket or SMTP handler
        "queued": {
            "class": "logging.handlers.QueueHandler",
            "handlers": ["slow"],
            "respect_handler_level": True,
        },
    },
    "loggers": {"catalog": {"handlers": ["queued"], "level": "INFO", "propagate": False}},
})
logging.getHandlerByName("queued").listener.start()  # dictConfig creates the listener but does not start it

The comment on the last line is the part the documentation page makes easy to miss and the scratch test confirmed: without the start() call the listener’s thread does not exist and records are queued but never delivered. Start the listener once per process (for example in AppConfig.ready() or in the module that calls dictConfig) and call listener.stop() at shutdown. Django’s own handlers (AdminEmailHandler) can sit behind the queue by listing them under "handlers".

Context variables, the request-id mechanism of Structured logging, work in async views and in sync views served from asgiref’s thread pool alike; that is why `ContextVar rather than threading.local is the right tool.

Error reporting

When DEBUG is False the browser shows a bland 500 page and Django reports the error by other means. The default channels are an email to ADMINS for every server error and an email to MANAGERS for broken links; third-party trackers take over from there.

Server errors by email: ADMINS and AdminEmailHandler

When DEBUG is False and AdminEmailHandler is configured (the default logging does it), Django emails the people in ADMINS whenever the code raises an unhandled exception that ends as a response with status 500 or more. The email contains a description of the error, the full traceback and details of the request. It is sent through the logging framework, so everything above about handlers, filters and levels applies to it.

Error-email settings (config/settings.py)
ADMINS = ["ops@example.com", '"Bookshelf Ops" <oncall@example.com>']
MANAGERS = ["content@example.com"]
SERVER_EMAIL = "bookshelf-errors@example.com"

Details that decide whether the mail arrives:

  • ADMINS is a list of address strings. Before Django 6.0 it required (name, address) tuples whose name was never used; the tuple form is deprecated since 6.0. To include a display name, write the address as '"Name" <address>', as above.

  • The sender is SERVER_EMAIL, which defaults to root@localhost; many providers reject that address, so set it.

  • The mail itself needs a working mail configuration. Until 6.1 that meant EMAIL_HOST, EMAIL_HOST_USER, EMAIL_HOST_PASSWORD and friends. Since 6.1 it is the MAILERS setting (see below).

  • The subject carries the prefix EMAIL_SUBJECT_PREFIX and says EXTERNAL IP or INTERNAL IP depending on whether the client address is in INTERNAL_IPS.

A test with the Django test client shows the whole chain, from the exception to the message in the in-memory outbox. The test runner switches the email backend to the in-memory one automatically:

The 500 error is mailed to ADMINS (catalog/tests.py)
from django.core import mail
from django.test import Client, TestCase


class AdminEmails(TestCase):
    def test_500_mails_admins(self):
        client = Client(raise_request_exception=False)  # do not re-raise in the test
        response = client.get("/boom/")
        self.assertEqual(response.status_code, 500)
        self.assertEqual(len(mail.outbox), 1)
        message = mail.outbox[0]
        self.assertEqual(message.to, ["ops@example.com", '"Bookshelf Ops" <oncall@example.com>'])
        self.assertEqual(message.from_email, "bookshelf-errors@example.com")

The message produced in the scratch run had the subject [Django] ERROR (EXTERNAL IP): Internal Server Error: /boom/. Two testing traps surfaced. First, wrapping the request in assertLogs("django.request") as well makes the email vanish (len(mail.outbox) was 0): assertLogs replaces the logger’s handlers and stops propagation for the duration of the block, so the AdminEmailHandler on the parent django logger never sees the record. Test the log and the email in separate tests. Second, the report is long; see Filtering sensitive data out of reports before deciding what it may contain.

AdminEmailHandler options

AdminEmailHandler(include_html=False, email_backend=None, reporter_class=None, using=None) takes these keys in the handler dictionary:

  • include_html attaches an HTML copy of the debug page that would be shown if DEBUG were True: tracebacks with local variables at every level and the settings. The Django documentation warns that sending that over email is generally a bad idea. The scratch test showed the HTML alternative with MIME type text/html.

  • reporter_class is the dotted path of an ExceptionReporter subclass that customises the text of the report (Filtering sensitive data out of reports).

  • using (new in 6.1) is the alias of a mailer from MAILERS to send through; if the alias is not configured, no email is sent and no error is raised.

  • email_backend (deprecated in 6.1; unsupported when MAILERS is defined or using is given) used to override the backend. Use using instead.

send_mail(subject, message, *args, **kwargs) is the method to override in a subclass to change how the mail is sent.

MAILERS (Django 6.1)

Django 6.1 adds MAILERS, a dictionary of named mail configurations in the style of CACHES and DATABASES; it replaces EMAIL_BACKEND and the EMAIL_* connection settings, which keep working with deprecation warnings until Django 2028. Credentials come from the environment, never from the file:

MAILERS with two mailers, one used for error mail (settings fragment)
import os

MAILERS = {
    "default": {
        "BACKEND": "django.core.mail.backends.smtp.EmailBackend",
        "OPTIONS": {
            "host": os.environ["SMTP_HOST"],
            "port": 587,
            "username": os.environ["SMTP_USER"],
            "password": os.environ["SMTP_PASSWORD"],
            "use_tls": True,
        },
    },
    "alerts": {"BACKEND": "django.core.mail.backends.console.EmailBackend"},
}

LOGGING["handlers"]["mail_admins"]["using"] = "alerts"  # error mail goes through the "alerts" mailer

With that settings module, manage.py check reported no issues, and a record logged at ERROR to django.request was rendered by the console mailer (the alerts alias) rather than the SMTP one. In the test suite a handler built with AdminEmailHandler(using="alerts") delivered one message through a locmem alerts mailer. Note that in the email text the password inside OPTIONS was already shown as stars: the default filter hides any nested key whose name contains PASS.

The same release removed the undocumented AdminEmailHandler.connection() method, so a subclass that overrode send_mail() and called connection() must stop doing so.

Django can also email MANAGERS about broken links (404 responses) when DEBUG is False and MIDDLEWARE contains django.middleware.common.BrokenLinkEmailsMiddleware. It mails only when the request has a referer and the referer is not the requested URL itself, because 404s without a referer are mostly people typing URLs or bots. Put it toward the top of MIDDLEWARE, before middleware that intercepts 404s such as LocaleMiddleware or FlatpageFallbackMiddleware. IGNORABLE_404_URLS is a list of compiled regular expressions for URLs not worth reporting; the documentation’s example excludes the usual crawler requests:

Ignoring conventional 404s (settings fragment)
import re

IGNORABLE_404_URLS = [
    re.compile(r"\.(php|cgi)$"),
    re.compile(r"^/phpmyadmin/"),
    re.compile(r"^/favicon\.ico$"),
]
Broken-link email behaviour (catalog/tests.py)
from django.core import mail
from django.test import TestCase


class BrokenLinks(TestCase):
    def test_404_with_referer_mails_managers(self):
        self.client.get("/missing/", headers={"referer": "http://other.example/page"})
        self.assertEqual(len(mail.outbox), 1)
        self.assertEqual(mail.outbox[0].to, ["content@example.com"])

    def test_ignorable_and_refererless_404s_are_silent(self):
        self.client.get("/missing/")  # no referer
        self.client.get("/wp/x.php", headers={"referer": "http://other.example/page"})
        self.client.get("/favicon.ico", headers={"referer": "http://other.example/page"})
        self.assertEqual(len(mail.outbox), 0)

The first test produced the subject [Django] Broken link on testserver. To ignore requests from crawlers by other criteria, subclass the middleware and override its methods (Django 6.1 moved its implementation onto the mailers framework). Separately, 404 events are logged on django.request at WARNING; by default nothing sends those anywhere, so a handler on that logger is the way to feed a log-based report.

The limits of email

Email reporting is pragmatic and fragile: one broken dependency can produce thousands of identical messages a minute and trip the mail provider’s rate limits; there is no grouping, no counts, no resolved state; and the report may contain personal data that travels unencrypted. The Django documentation says as much and points to third-party services. A reasonable setup keeps the ADMINS mail for small sites and for a last-resort channel (set include_html to False), and uses a tracker (Sentry) for everything else.

Filtering sensitive data out of reports

By default an error report holds the traceback, every frame’s local variables, the request attributes (including POST data and cookies, with some filtering) and the Django settings. The documentation puts it bluntly: filtering sensitive data is a hard problem, it is nearly impossible to guarantee that nothing leaks, so reports must be available only to trusted team members and not sent unencrypted over the internet. The tools below reduce the risk; they do not remove it.

@sensitive_variables and @sensitive_post_parameters

sensitive_variables(*variables) marks local variables of a function (a view or any other callable) whose values must be replaced by stars in reports; with no arguments it hides every local variable of the function. sensitive_post_parameters(*parameters) marks POST parameters of a view the same way, and with no arguments hides all of them. The built-in authentication views (login, password reset confirm, password change, and the user admin’s add and password change views) hide every POST parameter without you asking. Since Django 5.0 both decorators work on async def functions.

Hiding card data (catalog/views.py)
from django.views.decorators.debug import sensitive_post_parameters, sensitive_variables


class PaymentError(Exception):
    pass


@sensitive_variables("card_number", "gateway_token")
def _charge(request):
    card_number = request.POST["card_number"]
    gateway_token = request.POST["gateway_token"]
    order_note = request.POST["note"]
    raise PaymentError(f"gateway refused order ({order_note})")


@sensitive_post_parameters("card_number", "gateway_token")
def checkout(request):
    return _charge(request)


@sensitive_variables("card_number")
async def _acharge(request):
    card_number = request.POST["card_number"]
    raise PaymentError("async gateway refused")


@sensitive_post_parameters("card_number")
async def acheckout(request):
    return await _acharge(request)


def plain_checkout(request):
    # Deliberately undecorated, to show what leaks.
    card_number = request.POST["card_number"]
    raise PaymentError("no filtering")
Proving it with the mail outbox (catalog/test_sensitive.py)
import asyncio
import secrets

from django.core import mail
from django.test import AsyncClient, Client, TestCase


class SensitiveData(TestCase):
    def setUp(self):
        self.card = secrets.token_urlsafe(12)  # generated, never a literal
        self.gateway = secrets.token_urlsafe(12)
        self.client = Client(raise_request_exception=False)

    def post(self, url):
        return self.client.post(url, {"card_number": self.card, "gateway_token": self.gateway, "note": "gift"})

    def test_decorated_view_hides_values(self):
        self.assertEqual(self.post("/checkout/").status_code, 500)
        body = mail.outbox[0].body
        self.assertNotIn(self.card, body)
        self.assertNotIn(self.gateway, body)
        self.assertIn("gift", body)  # the non-sensitive field is still visible
        self.assertIn("********************", body)

    def test_undecorated_view_leaks(self):
        self.post("/plain-checkout/")
        self.assertIn(self.card, mail.outbox[0].body)

    def test_async_view(self):
        async def run():
            return await AsyncClient(raise_request_exception=False).post("/acheckout/", {"card_number": self.card})

        asyncio.run(run())
        self.assertNotIn(self.card, mail.outbox[0].body)

All four tests passed. The undecorated view leaked the generated card value into the email, which is the point: the default protects only what you mark. Two rules from the documentation: when the sensitive variable is also a function argument and the function has other decorators, @sensitive_variables must be the outermost decorator, or the argument is exposed as it passes through the others; and the decorators only annotate (the function or the request), the actual replacement is done by the reporter filter, so they have no effect on anything that does not use it, such as your own logger.error("…​", request.POST) calls.

A site-wide filter: SafeExceptionReporterFilter

The filter that does the replacement is django.views.debug.SafeExceptionReporterFilter. To apply rules to the whole site, subclass it and name the class in DEFAULT_EXCEPTION_REPORTER_FILTER. The attributes and methods the documentation lists as overridable are: cleansed_substitute (the replacement text, by default), hidden_settings (a compiled regular expression matching setting names and request.META keys to hide), is_active(request) (by default the filter is active when DEBUG is False), get_post_parameters(request) and get_traceback_frame_variables(request, tb_frame). The default hidden_settings is API|AUTH|TOKEN|KEY|SECRET|PASS|SIGNATURE|HTTP_COOKIE, case-insensitive; note that a setting called SENTRY_DSN or DATABASE_URL matches none of it.

A custom filter and reporter (catalog/reporting.py)
import re

from django.views.debug import ExceptionReporter, SafeExceptionReporterFilter


class BookshelfReporterFilter(SafeExceptionReporterFilter):
    cleansed_substitute = "[redacted]"
    # Extend the built-in pattern (API|AUTH|TOKEN|KEY|SECRET|PASS|SIGNATURE|HTTP_COOKIE).
    hidden_settings = re.compile(
        SafeExceptionReporterFilter.hidden_settings.pattern + r"|DSN|DATABASE_URL",
        flags=re.IGNORECASE,
    )
    hidden_post = re.compile(r"card|iban|ssn", flags=re.IGNORECASE)

    def is_active(self, request):
        # Filter even when DEBUG is True (the default only filters when it is False).
        return True

    def get_post_parameters(self, request):
        params = super().get_post_parameters(request)
        return {
            name: self.cleansed_substitute if self.hidden_post.search(name) else value
            for name, value in params.items()
        }


class BookshelfExceptionReporter(ExceptionReporter):
    def get_traceback_data(self):
        data = super().get_traceback_data()
        data["sys_path"] = []  # nobody needs the server's import path in an e-mail
        return data
Activating them (settings fragment)
DEFAULT_EXCEPTION_REPORTER_FILTER = "catalog.reporting.BookshelfReporterFilter"
DEFAULT_EXCEPTION_REPORTER = "catalog.reporting.BookshelfExceptionReporter"

In the scratch project the undecorated view posted a generated card value together with settings called SENTRY_DSN and DATABASE_URL; with this filter active, none of the three values appeared in the email and the replacement text was [redacted]. A second test passed reporter_class="catalog.reporting.BookshelfExceptionReporter" to AdminEmailHandler and the email body contained Python Path: []. Three things to keep in mind:

  • ExceptionReporter (the class named in DEFAULT_EXCEPTION_REPORTER) compiles and formats the report; its main extension point is get_traceback_data(), and it uses the filter when preparing the data. It also has html_template_path, text_template_path, get_traceback_html() and get_traceback_text().

  • The default filter instance is cached on first use. A test that swaps DEFAULT_EXCEPTION_REPORTER_FILTER with override_settings has no effect until django.views.debug.get_default_exception_reporter_filter.cache_clear() is called (the scratch test did so, and registered the same call as cleanup). That function is an internal detail, not a documented API, so keep the test helper in one place.

  • You can choose per request: set request.exception_reporter_filter or request.exception_reporter_class inside a view to an instance of your class, for example only for authenticated users.

Subclasses must inherit from SafeExceptionReporterFilter (Django 3.1 made that mandatory for custom filters).

Rules of thumb

  • Mark every view that receives a credential, card number, token or national id with @sensitive_post_parameters, and every helper that holds one in a local with @sensitive_variables.

  • Name settings so they match hidden_settings, or widen the pattern; a DSN, a connection URL and a webhook URL are secrets too.

  • Keep include_html off, send error email only to people allowed to see production data, and prefer a tracker you control over email.

  • Do not log request bodies, headers such as Authorization and Cookie, or model instances with personal fields; use the redaction filter of Redacting your own messages as a backstop only.

  • Remember what is outside the filter: your own logger calls, the request object attached to django.request records (so a custom handler or an error tracker can see it), and the database contents behind a record id.

Sentry

Sentry is an error-tracking service (hosted or self-hosted) that groups the same exception into one issue, counts occurrences, tracks releases and keeps the context of each event. The setup below follows the Sentry SDK documentation, and Django needs no code change beyond initialising it. The package is sentry-sdk (2.71.0, 2026-09-28, requires Python 3.6 or newer); installing it with pip install sentry-sdk is enough. The Django integration is enabled automatically when Django is installed; adding DjangoIntegration() to integrations is only needed to set its options.

Initialising Sentry from the environment (catalog/sentry_setup.py)
import logging
import os

import sentry_sdk
from sentry_sdk.integrations.django import DjangoIntegration
from sentry_sdk.integrations.logging import LoggingIntegration

SENSITIVE_HEADERS = {"authorization", "cookie", "x-session-token"}


def scrub_event(event, hint):
    """Last line of defence: runs on every error event before it leaves the process."""
    headers = event.get("request", {}).get("headers", {})
    for name in list(headers):
        if name.lower() in SENSITIVE_HEADERS:
            headers[name] = "[scrubbed]"
    return event


def init_sentry(**overrides):
    dsn = os.environ.get("SENTRY_DSN")
    if not dsn:
        return  # local development and CI: Sentry stays off
    options = dict(
        dsn=dsn,
        environment=os.environ.get("APP_ENV", "production"),
        release=os.environ.get("GIT_SHA"),
        send_default_pii=False,  # the default; stated for the reader
        traces_sample_rate=0.05,
        integrations=[
            DjangoIntegration(),
            LoggingIntegration(level=logging.INFO, event_level=logging.ERROR),
        ],
        before_send=scrub_event,
    )
    options.update(overrides)
    sentry_sdk.init(**options)

Call init_sentry() at the end of settings.py, as early as possible in the process lifecycle, so that exceptions raised while Django starts are captured too.

What was verified against the scratch project, with a local transport function standing in for the network (the DSN was a generated placeholder pointing at an unresolvable host, so no event left the machine):

  • An unhandled exception in a view reached the transport as one error event with the configured release and environment and the URL path as transaction name. The event for a 500 was reported once, not once by Django’s signal and once by the django.request log record.

  • logging.getLogger("catalog.payments").error(…​) produced a second event (the LoggingIntegration default turns ERROR records into events and INFO and above into breadcrumbs, which is what event_level and level state explicitly above), while an INFO record added nothing but a breadcrumb.

  • With send_default_pii=False and a request served by runserver, the event had the request URL, method and headers but no user data. Both the SDK’s own scrubbing (the Authorization and Cookie headers arrived as [Filtered]) and the before_send function (a custom X-Session-Token header arrived as [scrubbed]) worked.

  • With the Django test client the request section of the event was empty: the SDK collects request data in its WSGI/ASGI wrapper, which the test client bypasses. Test Sentry behaviour with a real server process or by calling scrub_event directly.

The decisions behind each option:

  • DSN from the environment, off when missing. The DSN identifies the project and is accepted by anyone who has it, so treat it as configuration that does not belong in the repository, and make development and CI run without it.

  • send_default_pii=False. Leave it off unless you have decided what to do about personal data; turning it on attaches user id, email, username and cookies when django.contrib.auth is installed.

  • before_send is the hook for rules the SDK does not know: drop events (return None), redact fields, add tags.

  • release and environment let Sentry attribute an error to a deployment and separate staging from production; a git commit hash is a good release.

  • traces_sample_rate is performance tracing, independent of error capture; sample, do not set it to 1.0 in production.

  • The hostname. Events include the server name, which may be internal infrastructure naming; set server_name or scrub it when that matters.

Sentry does not replace logging: keep structured logs for the narrative of a request and use the tracker for failures that a person must act on. If you use another tracker, the structure is the same (initialise early, from the environment, with a scrubbing hook); the Django-side contract is only the logging framework, the got_request_exception signal and middleware.

Testing logs and error reports

  • self.assertLogs(logger, level) is a context manager that fails if nothing was logged at that level and exposes cm.records and cm.output; self.assertNoLogs(logger, level) asserts the opposite. Both come from unittest. They replace the logger’s handlers and stop propagation inside the block (see the trap under Server errors by email: ADMINS and AdminEmailHandler).

  • The test runner swaps the email backend for the in-memory one, so mail.outbox holds error and broken-link emails; with Client(raise_request_exception=False) a view exception becomes a 500 response and the email is sent, instead of the exception propagating into the test.

  • logging.disable(logging.CRITICAL) in a setUp (with logging.disable(logging.NOTSET) in cleanup) silences log noise from tests that deliberately hit errors.

  • Do not initialise Sentry in the test settings; test your before_send function as a plain function.

  • A configuration that fails to import (a typo in "()") is found by manage.py check or any test run, because LOGGING is applied at set-up; run one of them in CI.

Running it in production

  • Log to the standard streams. In Docker, Kubernetes and most platforms the platform collects what the process prints. A StreamHandler to stderr (or stdout) is the right production handler, and rotation, retention and shipping are the platform’s job (the twelve-factor "logs as event streams" rule).

  • The server’s own logs. Gunicorn writes its access log only when told to: --access-logfile - sends it to stdout, --error-logfile - sends the error log to stderr, --capture-output redirects the application’s stdout and stderr into the error log, and --log-level sets the level; --log-config-json and --log-config load a full configuration. Uvicorn has --log-level, --access-log / --no-access-log and --log-config. Both servers use their own loggers, not django.server.

  • One configuration, many processes. Every worker process runs django.setup() and therefore applies LOGGING; a QueueListener thread exists per process.

  • Set levels per environment through variables (DJANGO_LOG_LEVEL, LOG_JSON) as above, and keep DEBUG off; django.db.backends logging only works with DEBUG=True, so in production use the database’s own slow-query log.

  • Do not log to a file in a container unless a volume and a rotation strategy exist; a file inside an ephemeral container disappears with it.

  • Check the checklist. manage.py check --deploy does not inspect LOGGING, so review it with the other items of the deployment checklist (see Security and Settings & Configuration).

Pitfalls

  • Forgetting "disable_existing_loggers": False silences every logger that is created before dictConfig runs.

  • Expecting INFO lines from your own code in production without a handler or a level for the catalog logger.

  • Putting log calls or dictConfig before the settings they depend on in settings.py.

  • Using f-strings in log calls (cost and grouping) and logging secrets in a message that no Django filter can see.

  • Attaching AdminEmailHandler to a logger that logs every request: one outage floods the mailbox.

  • Using a filter on a parent logger to filter records of its children (filters on a logger do not apply to records that propagate to it from children; attach to the handler instead).

  • Wrapping a request in assertLogs and wondering why the admin email is not sent.

  • Sending the debug page by email (include_html) and treating the report as safe because one view was decorated.

  • Trusting hidden_settings for names that do not match its pattern (SENTRY_DSN, DATABASE_URL, webhook URLs).

  • Letting a slow handler (SMTP, remote syslog, a slow disk) run on the event loop without a QueueHandler.

  • Enabling Sentry’s send_default_pii without a policy for personal data.

Where the books are outdated

The four reference books cover Django 1.11 to 4 and their logging advice is good where it is about habits: use levels, log exceptions with logger.exception, put logging in every module, keep secrets out of logs and reports. Several details have moved on:

  • Configuration style. Older examples build LOGGING with %-style formats and no explicit disable_existing_loggers; the current documentation recommends setting it to False, and the { style is as supported as %.

  • The default configuration. A description of a single "mail admins when DEBUG is False`" rule predates the `django.server logger, which always logs the development server’s request lines to the console; copying a config that assumes the old defaults can give duplicate lines.

  • Email settings. Chapters on error emails configure EMAIL_HOST, EMAIL_HOST_USER, EMAIL_HOST_PASSWORD and EMAIL_BACKEND. Since Django 6.1 the current way is MAILERS, and those settings are deprecated until Django 2028; ADMINS as (name, address) tuples (shown in every older book) has been deprecated since 6.0.

  • Sensitive data. Older sections show sensitive_variables and sensitive_post_parameters on sync functions only; both have worked on async def since 5.0. The default hidden_settings pattern matches more names than in the 2.x era (SIGNATURE, HTTP_COOKIE, and since 5.2 AUTH), and since 3.1 cleansed_substitute and hidden_settings are documented attributes of the filter class, so custom filters no longer need to reimplement the cleansing method.

  • Python itself. Pre-3.12 text cannot configure QueueHandler through dictConfig; the logging.getHandlerByName() accessor used in this page is also new in 3.12.

  • Third-party logging. Text that recommends Raven (the original Sentry client) is out of date: Sentry’s current Python client is sentry-sdk, whose Django integration is automatic.

  • Debugging by print. Chapters that debug with print() and the development server’s console should use a logger, which works under every deployment style.

What changed recently

Version Change

3.0

AdminEmailHandler accepts a reporter_class (an ExceptionReporter subclass) to customise the traceback text.

3.1

SafeExceptionReporterFilter gains cleansed_substitute and hidden_settings, and filters sensitive request.META values as well as settings; DEFAULT_EXCEPTION_REPORTER allows a custom reporter class; custom filters must inherit from SafeExceptionReporterFilter.

4.0

The django.db.backends records carry the database alias as extra context.

4.2

django.db.backends logs transaction statements (BEGIN, COMMIT, ROLLBACK) at DEBUG.

5.0

sensitive_variables() and sensitive_post_parameters() can be used with asynchronous functions.

5.2

hidden_settings also treats values as sensitive when the setting name includes AUTH.

6.0

ADMINS and MANAGERS are lists of address strings; the (name, address) tuple form is deprecated.

6.1

MAILERS setting; AdminEmailHandler gains using, and email_backend is deprecated; the undocumented AdminEmailHandler.connection() method is removed; BrokenLinkEmailsMiddleware moves onto the mailers framework.

Python 3.12

QueueHandler and QueueListener configurable from dictConfig; logging.getHandlerByName().