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 |
|
|
Know what Django already does |
|
|
Change where records go |
One |
|
Silence or redirect one noisy logger |
A logger entry with a |
|
Machine-readable logs |
A JSON |
|
Get told about 500 errors |
Set |
|
Get told about broken links |
|
Broken links: BrokenLinkEmailsMiddleware, MANAGERS and IGNORABLE_404_URLS |
Keep secrets out of reports |
|
|
Track errors across servers |
Sentry (or another tracker): |
|
Test it |
|
|
Run it in production |
Log to stdout/stderr, let the platform collect the stream, keep levels in environment variables. |
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:
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}")
Source: Make a basic logging call
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) andCRITICAL(the process cannot continue). A logger discards records below its level; a handler discards records below its own. -
Use
logger.exception()insideexceptblocks. It logs atERRORand 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 appliesLOGGINGas part ofdjango.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 anAppConfig.ready().
Every record emitted by that logger flows through a small, fixed pipeline. Understanding it is the key to every configuration in this page.
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.
Source: The cast of players and Python Logging HOWTO
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 |
|---|---|
|
The |
|
The |
Always |
|
Everything else |
Python’s own defaults: |
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:
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
Source: Default logging definition
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.
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},
},
}
Source: Configuring logging and LOGGING
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": "{"choosesstr.format()placeholders in a formatter; the default style is%. Pick one and use it everywhere. -
"propagate": Falseondjangostops Django records at that logger. Leave it out (defaultTrue) 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
consolehandler above has no level, so it prints everything its loggers let through;mail_adminsonly acceptsERRORand above. -
The
disable_existing_loggerspitfall. If the key is missing,dictConfigtreats it asTrueand 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 reporteddisabledasTrueafter a configuration without the key, andFalseafter 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).
Source: Configure responsive logging
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:
"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:
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"},
})
Source: Disabling logging configuration
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 |
|---|---|
|
The parent of the whole family; Django never logs to it directly. |
|
One record per 4xx ( |
|
The |
|
Template rendering; a missing context variable is a |
|
Every application-level SQL statement at |
|
Schema changes made by migrations (not the SQL inside |
|
One sub-logger per |
|
File-change detection in the development server. |
|
|
Source: Django logging extensions
The scratch project exercised the ones that matter for a web application. assertLogs records the logger’s output and
exposes the extra fields:
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)
Source: django.request and django.security.*
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:
LOGGING = {
# ...
"handlers": {
"null": {"class": "logging.NullHandler"},
},
"loggers": {
"django.security.DisallowedHost": {
"handlers": ["null"],
"propagate": False,
},
},
}
Source: django.security.*
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.StreamHandlerwrites tosys.stderr(or a stream you give it): the right default for containers and platforms, see Running it in production. -
logging.FileHandlerwrites to a file;logging.handlers.WatchedFileHandler,RotatingFileHandlerandTimedRotatingFileHandlerreopen or rotate the file. PreferWatchedFileHandlerwhenlogrotatedoes the rotating, and never useFileHandlerwith a path the application user cannot write to. -
logging.handlers.SysLogHandler,HTTPHandler,SocketHandlerandSMTPHandlership records to a collector; each does network I/O inside the logging call, which matters in async code (Sync, async and blocking handlers). -
logging.NullHandlerdiscards. -
logging.handlers.QueueHandlerhands records to another thread so slow handlers do not block the request (since Python 3.12 it can be configured fromdictConfig, see Sync, async and blocking handlers).
Source: logging.handlers and Configure a handler
Django’s three filters
-
django.utils.log.RequireDebugFalseandRequireDebugTruepass records only whensettings.DEBUGisFalse(respectivelyTrue); the defaultmail_adminshandler 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 returnsFalse. The documentation’s example keepsUnreadablePostError(a client that cancelled an upload) out of 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:
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:
# 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:
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:
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:
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.
Source: QueueHandler and Asynchronous support
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.
ADMINS = ["ops@example.com", '"Bookshelf Ops" <oncall@example.com>']
MANAGERS = ["content@example.com"]
SERVER_EMAIL = "bookshelf-errors@example.com"
Source: ADMINS, MANAGERS and SERVER_EMAIL
Details that decide whether the mail arrives:
-
ADMINSis 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 toroot@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_PASSWORDand friends. Since 6.1 it is theMAILERSsetting (see below). -
The subject carries the prefix
EMAIL_SUBJECT_PREFIXand saysEXTERNAL IPorINTERNAL IPdepending on whether the client address is inINTERNAL_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:
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")
Source: Server errors and Email services
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_htmlattaches an HTML copy of the debug page that would be shown ifDEBUGwereTrue: 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 typetext/html. -
reporter_classis the dotted path of anExceptionReportersubclass that customises the text of the report (Filtering sensitive data out of reports). -
using(new in 6.1) is the alias of a mailer fromMAILERSto send through; if the alias is not configured, no email is sent and no error is raised. -
email_backend(deprecated in 6.1; unsupported whenMAILERSis defined orusingis given) used to override the backend. Useusinginstead.
send_mail(subject, message, *args, **kwargs) is the method to override in a subclass to change how the mail is sent.
Source: AdminEmailHandler
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:
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
Source: MAILERS, Multiple mailers and AdminEmailHandler
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.
Broken links: BrokenLinkEmailsMiddleware, MANAGERS and IGNORABLE_404_URLS
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:
import re
IGNORABLE_404_URLS = [
re.compile(r"\.(php|cgi)$"),
re.compile(r"^/phpmyadmin/"),
re.compile(r"^/favicon\.ico$"),
]
Source: 404 errors and IGNORABLE_404_URLS
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)
Source: BrokenLinkEmailsMiddleware
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.
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")
Source: Filtering sensitive information
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)
Source: Filtering sensitive information
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.
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
Source: Custom error reports
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 inDEFAULT_EXCEPTION_REPORTER) compiles and formats the report; its main extension point isget_traceback_data(), and it uses the filter when preparing the data. It also hashtml_template_path,text_template_path,get_traceback_html()andget_traceback_text(). -
The default filter instance is cached on first use. A test that swaps
DEFAULT_EXCEPTION_REPORTER_FILTERwithoverride_settingshas no effect untildjango.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_filterorrequest.exception_reporter_classinside 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_htmloff, 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
AuthorizationandCookie, 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
loggercalls, therequestobject attached todjango.requestrecords (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.
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
errorevent with the configuredreleaseandenvironmentand the URL path as transaction name. The event for a500was reported once, not once by Django’s signal and once by thedjango.requestlog record. -
logging.getLogger("catalog.payments").error(…)produced a second event (theLoggingIntegrationdefault turnsERRORrecords into events andINFOand above into breadcrumbs, which is whatevent_levelandlevelstate explicitly above), while anINFOrecord added nothing but a breadcrumb. -
With
send_default_pii=Falseand a request served byrunserver, the event had the request URL, method and headers but no user data. Both the SDK’s own scrubbing (theAuthorizationandCookieheaders arrived as[Filtered]) and thebefore_sendfunction (a customX-Session-Tokenheader 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_eventdirectly.
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 whendjango.contrib.authis installed. -
before_sendis the hook for rules the SDK does not know: drop events (returnNone), redact fields, add tags. -
releaseandenvironmentlet Sentry attribute an error to a deployment and separate staging from production; a git commit hash is a good release. -
traces_sample_rateis 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_nameor 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 exposescm.recordsandcm.output;self.assertNoLogs(logger, level)asserts the opposite. Both come fromunittest. 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.outboxholds error and broken-link emails; withClient(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 asetUp(withlogging.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_sendfunction as a plain function. -
A configuration that fails to import (a typo in
"()") is found bymanage.py checkor any test run, becauseLOGGINGis applied at set-up; run one of them in CI.
Source: unittest.TestCase.assertLogs and Email services
Running it in production
-
Log to the standard streams. In Docker, Kubernetes and most platforms the platform collects what the process prints. A
StreamHandlerto 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-outputredirects the application’s stdout and stderr into the error log, and--log-levelsets the level;--log-config-jsonand--log-configload a full configuration. Uvicorn has--log-level,--access-log/--no-access-logand--log-config. Both servers use their own loggers, notdjango.server. -
One configuration, many processes. Every worker process runs
django.setup()and therefore appliesLOGGING; aQueueListenerthread exists per process. -
Set levels per environment through variables (
DJANGO_LOG_LEVEL,LOG_JSON) as above, and keepDEBUGoff;django.db.backendslogging only works withDEBUG=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 --deploydoes not inspectLOGGING, so review it with the other items of the deployment checklist (see Security and Settings & Configuration).
Pitfalls
-
Forgetting
"disable_existing_loggers": Falsesilences every logger that is created beforedictConfigruns. -
Expecting
INFOlines from your own code in production without a handler or a level for thecataloglogger. -
Putting log calls or
dictConfigbefore the settings they depend on insettings.py. -
Using f-strings in log calls (cost and grouping) and logging secrets in a message that no Django filter can see.
-
Attaching
AdminEmailHandlerto 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
assertLogsand 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_settingsfor 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_piiwithout 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
LOGGINGwith%-style formats and no explicitdisable_existing_loggers; the current documentation recommends setting it toFalse, and the{style is as supported as%. -
The default configuration. A description of a single "mail admins when
DEBUGisFalse`" rule predates the `django.serverlogger, 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_PASSWORDandEMAIL_BACKEND. Since Django 6.1 the current way isMAILERS, and those settings are deprecated until Django 2028;ADMINSas(name, address)tuples (shown in every older book) has been deprecated since 6.0. -
Sensitive data. Older sections show
sensitive_variablesandsensitive_post_parameterson sync functions only; both have worked onasync defsince 5.0. The defaulthidden_settingspattern matches more names than in the 2.x era (SIGNATURE,HTTP_COOKIE, and since 5.2AUTH), and since 3.1cleansed_substituteandhidden_settingsare 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
QueueHandlerthroughdictConfig; thelogging.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 |
|
3.1 |
|
4.0 |
The |
4.2 |
|
5.0 |
|
5.2 |
|
6.0 |
|
6.1 |
|
Python 3.12 |
|