Django logging
last modified November 13, 2026 Python 3.15
When something goes wrong in production, the log is often the only witness. Django uses the logging module of the Python standard library and configures it from one setting, LOGGING.
In this article we write log messages in views, configure handlers and formatters, tag every message with the identifier of the request, write JSON lines to a rotating file, look at the loggers that Django itself writes to, including the SQL log
and the security log, send errors to the administrators by email, and test the output. The examples use Django 6.1 and Python 3.15.
Loggers, handlers, filters, and formatters
The logging module has four kinds of objects. A logger has a dotted name and a level; code calls its methods debug(), info(), warning(), error(), and critical(). A record that passes the level of
the logger is given to the handlers of the logger, which send it somewhere: a stream, a file, an email. Each handler has its own level, a formatter that turns the record into text, and optional filters that can reject
a record or add data to it. The names form a hierarchy: the logger orders.views is a child of orders, which is a child of the root logger. A logger without a level uses the level of its nearest ancestor, and after its own handlers
a record goes to the handlers of the parents, unless propagate is false. The convention is to create a logger in every module with logging.getLogger(__name__), so that the hierarchy follows the packages.
The example project
The project is created with uv; the steps are explained in
Django introduction. The commands below create a project
directory, install Django, generate the project with the configuration package
config, start the app orders, and create the folder demos for demo scripts.
$ mkdir logging $ cd logging $ uv init --bare $ uv add django $ uv run django-admin startproject config . $ uv run python manage.py startapp orders $ mkdir demos $ mkdir logs
The trailing dot in startproject config . creates manage.py and the
config package in the current directory instead of one level deeper. A new app is
ignored by Django until it is listed in INSTALLED_APPS, so we add
the app to the list in config/settings.py; the other
entries are generated by startproject.
INSTALLED_APPS = [
'django.contrib.admin',
'django.contrib.auth',
'django.contrib.contenttypes',
'django.contrib.sessions',
'django.contrib.messages',
'django.contrib.staticfiles',
'orders',
]
The listings in this article are of two kinds. A listing with a solid blue border and the label
Project file is a file of the application: models, views, templates, tests, and
settings. A listing with a dashed amber border and the label Demo script is a small
helper in the demos folder; it sets up Django, runs a few lines of code, and prints
the results, so that we can watch the behaviour. Demo scripts are not part of the application;
we run one from the project directory with uv run python -m demos.name.
The last command creates the folder logs for the log file; the file handler of the configuration below does not create missing directories. The app orders has a model with a customer and a total.
from django.db import models
class Order(models.Model):
customer = models.CharField(max_length=60)
total = models.DecimalField(max_digits=8, decimal_places=2)
def __str__(self):
return f'Order {self.pk} for {self.customer}'
We create and apply the migration, and the script seed.py adds two orders.
$ uv run python manage.py makemigrations orders No changes detected in app 'orders' $ uv run python manage.py migrate | tail -n 2 Running migrations: No migrations to apply.
import os
import django
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings')
django.setup()
from orders.models import Order
Order.objects.all().delete()
Order.objects.create(customer='Alice', total='59.90')
Order.objects.create(customer='Bob', total='12.00')
print('orders:', Order.objects.count())
The script deletes the existing orders first, so it can be run again.
$ uv run python -m demos.seed orders: 2
Writing log messages
The module orders/views.py creates its logger with __name__, which is orders.views. The messages use the lazy form logger.debug('Looking for order %s', pk): the arguments are
formatted only when a handler really emits the record, which is cheaper than an f-string for the messages that are filtered out. The view detail logs a warning when the order is missing and then raises Http404. In the view pay the call
logger.exception(), which can be used only in an except block, logs at the level ERROR and appends the traceback; the argument extra attaches additional attributes to the record. The view crash does not catch anything, so
Django logs its exception.
import logging
from django.http import Http404, HttpResponse, JsonResponse
from .models import Order
logger = logging.getLogger(__name__)
class PaymentError(Exception):
pass
def charge(order):
raise PaymentError(f'the card of {order.customer} was declined')
def index(request):
logger.info('The index page was requested')
return HttpResponse('orders')
def detail(request, pk):
logger.debug('Looking for order %s', pk)
try:
order = Order.objects.get(pk=pk)
except Order.DoesNotExist:
logger.warning('Order %s does not exist', pk)
raise Http404('no such order')
logger.info('Order %s has the total %s', order.pk, order.total)
return JsonResponse({'id': order.pk, 'customer': order.customer})
def pay(request, pk):
order = Order.objects.get(pk=pk)
try:
charge(order)
except PaymentError:
logger.exception('Payment failed', extra={'order_id': order.pk})
return HttpResponse('payment failed', status=502)
return HttpResponse('paid')
def count(request):
return JsonResponse({'orders': Order.objects.count()})
def crash(request):
raise RuntimeError('something unexpected')
The URL patterns connect the views with paths, and the project includes them under /orders/.
from django.urls import path
from . import views
urlpatterns = [
path('', views.index),
path('count/', views.count),
path('crash/', views.crash),
path('<int:pk>/', views.detail),
path('<int:pk>/pay/', views.pay),
]
The root URL configuration includes the URLs of the app.
from django.urls import include, path
urlpatterns = [
path('orders/', include('orders.urls')),
]
The paths and the names of the views are all that the demo scripts need.
Configuring LOGGING
The file orders/logs.py contains the helpers that the configuration uses. A context variable holds the identifier of the current request, which the middleware RequestIdMiddleware sets from the header
X-Request-ID or generates, and removes after the request; it also stores it on the request object. The filter RequestIdFilter adds the identifier to every record as the attribute request_id, so the format string of a formatter can use it.
It looks at the request first: Django logs the 4xx and 5xx responses of the django.request logger after the middleware chain has finished, when the context variable is already reset, but it passes the request in the record. The class JsonFormatter writes a record as one line of JSON,
including the extra attributes and the last line of the traceback. Finally, StdoutHandler is a stream handler that looks up sys.stdout each time it writes, so that the test runner can capture its output.
import json
import logging
import sys
import uuid
from contextvars import ContextVar
request_id_var = ContextVar('request_id', default='-')
class StdoutHandler(logging.StreamHandler):
# Always writes to the current sys.stdout, which the test runner may replace.
def __init__(self):
super().__init__()
@property
def stream(self):
return sys.stdout
@stream.setter
def stream(self, value):
pass
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[:8]
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
class RequestIdFilter(logging.Filter):
def filter(self, record):
request = getattr(record, 'request', None)
record.request_id = getattr(request, 'request_id', None) or request_id_var.get()
return True
class JsonFormatter(logging.Formatter):
extra_fields = ('order_id', 'status_code')
def format(self, record):
data = {
'time': self.formatTime(record, '%H:%M:%S'),
'level': record.levelname,
'logger': record.name,
'request_id': getattr(record, 'request_id', '-'),
'message': record.getMessage(),
}
for field in self.extra_fields:
if hasattr(record, field):
data[field] = getattr(record, field)
if record.exc_info:
data['exception'] = self.formatException(record.exc_info).splitlines()[-1]
return json.dumps(data)
The setting LOGGING is a dictionary in the format of logging.config.dictConfig. Its version is always 1. With disable_existing_loggers set to false the loggers that Django and the libraries have already
created stay active; the default true would silence them. The dictionary has the sections filters, formatters, handlers, and loggers, which refer to each other by name, and the root logger. The key '()' names a class
or a factory to create the object. The middleware is the first item of MIDDLEWARE so that it wraps all the others, and the setting ADMINS lists the recipients of the error emails.
MIDDLEWARE = [
'orders.logs.RequestIdMiddleware',
'django.middleware.security.SecurityMiddleware',
...
]
...
ADMINS = [('Admin', 'admin@example.com')]
LOGGING = {
'version': 1,
'disable_existing_loggers': False,
'filters': {
'request_id': {'()': 'orders.logs.RequestIdFilter'},
'require_debug_false': {'()': 'django.utils.log.RequireDebugFalse'},
},
'formatters': {
'simple': {
'format': '{levelname:<7} {name} [{request_id}] {message}',
'style': '{',
},
'json': {'()': 'orders.logs.JsonFormatter'},
},
'handlers': {
'console': {
'class': 'orders.logs.StdoutHandler',
'formatter': 'simple',
'filters': ['request_id'],
},
'file': {
'class': 'logging.handlers.RotatingFileHandler',
'filename': BASE_DIR / 'logs' / 'app.log',
'maxBytes': 1_000_000,
'backupCount': 3,
'formatter': 'json',
'filters': ['request_id'],
},
'mail_admins': {
'class': 'django.utils.log.AdminEmailHandler',
'level': 'ERROR',
'filters': ['require_debug_false'],
},
},
'loggers': {
'orders': {
'handlers': ['console', 'file'],
'level': 'DEBUG',
'propagate': False,
},
'django.request': {
'handlers': ['console', 'file', 'mail_admins'],
'level': 'WARNING',
'propagate': False,
},
'django.security': {
'handlers': ['console'],
'level': 'WARNING',
'propagate': False,
},
'django.db.backends': {
'handlers': ['console'],
'level': 'INFO',
'propagate': False,
},
},
'root': {
'handlers': ['console'],
'level': 'WARNING',
},
}
The logger orders sends everything from DEBUG up to the console and to a file, and, with propagate false, not to the root. The logger django.request receives the warnings about 4xx responses and the errors about 5xx responses, and
has a third handler, mail_admins, which is limited to the level ERROR and active only when DEBUG is false. The logger django.db.backends is set to INFO, so that the SQL is not logged until we lower the level. The file handler writes to the directory
logs; RotatingFileHandler starts a new file when the size reaches one megabyte and keeps three old ones.
The logger hierarchy and the defaults of Django
Django has its own default configuration, DEFAULT_LOGGING, which is merged with ours (that is why disable_existing_loggers must be false). It sends the messages of the logger django at INFO and above to the console
if DEBUG is true, and to the administrators by email if DEBUG is false. The logger django.server prints the request lines of runserver. The script prints these defaults and shows, for some of our loggers, the level, the effective
level inherited from the ancestors, the flag propagate, and the handlers.
import os
import django
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings')
django.setup()
import logging
from django.utils.log import DEFAULT_LOGGING
print('Django default LOGGING:')
print(' handlers:', sorted(DEFAULT_LOGGING['handlers']))
for name, options in DEFAULT_LOGGING['loggers'].items():
print(f' logger {name!r}: handlers={options["handlers"]}, level={options.get("level", "-")}')
print()
for name in ('orders.views', 'orders', 'django.request', 'django.db.backends', 'django.template'):
logger = logging.getLogger(name)
handlers = [type(handler).__name__ for handler in logger.handlers]
print(f'{name:<20} level={logging.getLevelName(logger.level):<8} '
f'effective={logging.getLevelName(logger.getEffectiveLevel()):<8} '
f'propagate={logger.propagate!s:<5} handlers={handlers}')
The logger orders.views has no level and no handlers of its own; it inherits DEBUG from orders and passes its records to the handlers of that logger. The logger django.template inherits INFO from django and so reports nothing at DEBUG.
$ uv run python -m demos.config Django default LOGGING: handlers: ['console', 'django.server', 'mail_admins'] logger 'django': handlers=['console', 'mail_admins'], level=INFO logger 'django.server': handlers=['django.server'], level=INFO orders.views level=NOTSET effective=DEBUG propagate=True handlers=[] orders level=DEBUG effective=DEBUG propagate=False handlers=['StdoutHandler', 'RotatingFileHandler'] django.request level=WARNING effective=WARNING propagate=False handlers=['StdoutHandler', 'RotatingFileHandler', 'AdminEmailHandler'] django.db.backends level=INFO effective=INFO propagate=False handlers=['StdoutHandler'] django.template level=NOTSET effective=INFO propagate=True handlers=[]
Logging requests and errors
The script requests five URLs with the test client and gives each request its own identifier. The console handler writes to the standard output with the format {levelname} {name} [{request_id}] {message}.
import os
import django
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings')
django.setup()
from django.test import Client
from django.test.utils import setup_test_environment
setup_test_environment()
from django.test import Client
client = Client(raise_request_exception=False)
for number, path in enumerate(['/orders/', '/orders/1/', '/orders/99/', '/orders/1/pay/', '/orders/crash/'], 1):
print(f'--- GET {path}')
response = client.get(path, headers={'X-Request-ID': f'req-{number:04d}'})
print(f'--- {response.status_code}')
The index request writes one INFO message. The view detail writes DEBUG and INFO messages, and for a missing order a WARNING followed by the WARNING of django.request about the 404 response; both carry the same request identifier, so the lines of a request
can be found with a search. The failed payment writes our ERROR with the traceback, and then django.request reports the status 502. The last request shows what Django does with an unhandled exception: it logs the ERROR with the traceback and returns the response with the status 500.
Django logs the responses with the status 400-499 as WARNING and 500-599 as ERROR.
$ uv run python -m demos.requests
--- GET /orders/
INFO orders.views [req-0001] The index page was requested
--- 200
--- GET /orders/1/
DEBUG orders.views [req-0002] Looking for order 1
INFO orders.views [req-0002] Order 1 has the total 59.90
--- 200
--- GET /orders/99/
DEBUG orders.views [req-0003] Looking for order 99
WARNING orders.views [req-0003] Order 99 does not exist
WARNING django.request [req-0003] Not Found: /orders/99/
--- 404
--- GET /orders/1/pay/
ERROR orders.views [req-0004] Payment failed
Traceback (most recent call last):
File "./orders/views.py", line 37, in pay
charge(order)
~~~~~~^^^^^^^
File "./orders/views.py", line 15, in charge
raise PaymentError(f'the card of {order.customer} was declined')
orders.views.PaymentError: the card of Alice was declined
ERROR django.request [req-0004] Bad Gateway: /orders/1/pay/
--- 502
--- GET /orders/crash/
ERROR django.request [req-0005] Internal Server Error: /orders/crash/
Traceback (most recent call last):
File "./.venv/lib/python3.15/site-packages/django/core/handlers/exception.py", line 56, in inner
response = get_response(request)
File "./.venv/lib/python3.15/site-packages/django/core/handlers/base.py", line 199, in _get_response
response = wrapped_callback(request, *callback_args, **callback_kwargs)
File "./orders/views.py", line 49, in crash
raise RuntimeError('something unexpected')
RuntimeError: something unexpected
--- 500
JSON logs in a rotating file
Humans read the console output; log collectors prefer a structured format. The handler file uses JsonFormatter, so each record is one JSON object on a line. The script empties the file, makes three requests, and prints the file.
import os
import django
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings')
django.setup()
from django.test import Client
from django.test.utils import setup_test_environment
setup_test_environment()
import logging
import os
from django.conf import settings
from django.test import Client
log_file = settings.BASE_DIR / 'logs' / 'app.log'
if log_file.exists():
os.truncate(log_file, 0)
client = Client(raise_request_exception=False)
for number, path in enumerate(['/orders/1/', '/orders/99/', '/orders/2/pay/'], 1):
client.get(path, headers={'X-Request-ID': f'req-{number:04d}'})
for handler in logging.getLogger('orders').handlers:
handler.flush()
print(f'{log_file.name}: {log_file.stat().st_size} bytes')
for line in log_file.read_text().splitlines():
print(line)
The lines contain the time, the level, the logger, the request identifier, and the message; the extra data order_id and the status_code of django.request are fields. The exception is reduced to its last line to keep the line short;
a real system might keep the whole traceback in a field. When the file reaches maxBytes it is renamed to app.log.1, the older ones are shifted, and the oldest is deleted. Rotation by time is done by TimedRotatingFileHandler. With several processes writing to one file
a rotating handler is unsafe, and it is better to log to the standard output and let the platform collect it.
$ uv run python -m demos.files
DEBUG orders.views [req-0001] Looking for order 1
INFO orders.views [req-0001] Order 1 has the total 59.90
DEBUG orders.views [req-0002] Looking for order 99
WARNING orders.views [req-0002] Order 99 does not exist
WARNING django.request [req-0002] Not Found: /orders/99/
ERROR orders.views [req-0003] Payment failed
Traceback (most recent call last):
File "./orders/views.py", line 37, in pay
charge(order)
~~~~~~^^^^^^^
File "./orders/views.py", line 15, in charge
raise PaymentError(f'the card of {order.customer} was declined')
orders.views.PaymentError: the card of Bob was declined
ERROR django.request [req-0003] Bad Gateway: /orders/2/pay/
app.log: 1028 bytes
{"time": "20:54:14", "level": "DEBUG", "logger": "orders.views", "request_id": "req-0001", "message": "Looking for order 1"}
{"time": "20:54:14", "level": "INFO", "logger": "orders.views", "request_id": "req-0001", "message": "Order 1 has the total 59.90"}
{"time": "20:54:14", "level": "DEBUG", "logger": "orders.views", "request_id": "req-0002", "message": "Looking for order 99"}
{"time": "20:54:14", "level": "WARNING", "logger": "orders.views", "request_id": "req-0002", "message": "Order 99 does not exist"}
{"time": "20:54:14", "level": "WARNING", "logger": "django.request", "request_id": "req-0002", "message": "Not Found: /orders/99/", "status_code": 404}
{"time": "20:54:14", "level": "ERROR", "logger": "orders.views", "request_id": "req-0003", "message": "Payment failed", "order_id": 2, "exception": "orders.views.PaymentError: the card of Bob was declined"}
{"time": "20:54:14", "level": "ERROR", "logger": "django.request", "request_id": "req-0003", "message": "Bad Gateway: /orders/2/pay/", "status_code": 502}
SQL queries
The logger django.db.backends logs every SQL statement with its duration at the level DEBUG, but only when DEBUG is true. Our configuration keeps it at INFO. The script lowers the level at run time and silences our own logger, so only the SQL is left.
In a project one can set the level in LOGGING to see all queries during development, which is a convenient way to spot N+1 problems (see Django query performance).
import os
import django
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings')
django.setup()
from django.test import Client
from django.test.utils import setup_test_environment
setup_test_environment()
import logging
from django.test import Client
logging.getLogger('django.db.backends').setLevel(logging.DEBUG)
logging.getLogger('orders').setLevel(logging.WARNING)
response = Client().get('/orders/count/', headers={'X-Request-ID': 'req-sql'})
print(response.content.decode())
The single query of the view is logged with its time in seconds, the parameters, and the alias of the database, and the line carries the identifier of the request.
$ uv run python -m demos.sql
DEBUG django.db.backends [req-sql] (0.000) SELECT COUNT(*) AS "__count" FROM "orders_order"; args=(); alias=default
{"orders": 2}
Errors by email and security events
The handler AdminEmailHandler sends a record by email to the addresses in ADMINS. It is attached to django.request at the level ERROR, so a 500 error produces an email, while a 404 does not. The filter
RequireDebugFalse keeps the handler quiet during development. The script sets DEBUG to false for the requests; the test environment collects the emails in mail.outbox. Sending is done by the mail system configured in the project (see Django email).
import os
import django
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings')
django.setup()
from django.test import Client
from django.test.utils import setup_test_environment
setup_test_environment()
import logging
from django.core import mail
from django.test import Client, override_settings
logging.getLogger('orders').handlers = [logging.NullHandler()]
logging.getLogger('django.request').handlers = [
handler for handler in logging.getLogger('django.request').handlers
if type(handler).__name__ == 'AdminEmailHandler'
]
with override_settings(DEBUG=False, ALLOWED_HOSTS=['testserver']):
Client(raise_request_exception=False).get('/orders/crash/')
Client(raise_request_exception=False).get('/orders/99/')
print('emails sent:', len(mail.outbox))
message = mail.outbox[0]
print('subject:', message.subject)
print('to :', message.to)
print('body :')
for line in message.body.splitlines()[:6]:
print(' ', line)
One email was sent, for the error 500. The subject contains the level, the kind of the IP address, and the message, and the body has the exception, the request data, and the traceback.
$ uv run python -m demos.mail emails sent: 1 subject: [Django] ERROR (EXTERNAL IP): Internal Server Error: /orders/crash/ to : ['admin@example.com'] body : Internal Server Error: /orders/crash/ RuntimeError at /orders/crash/ something unexpected Request Method: GET
The loggers below django.security report suspicious requests, such as a forbidden host name, a bad CSRF token, or a tampered session. A request with an unknown Host header is refused with the status 400 and logged in
django.security.DisallowedHost. These messages are worth a separate handler or alert in production. The script replaces the handlers of the logger with a small one that collects the records.
import os
import django
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings')
django.setup()
from django.test import Client
from django.test.utils import setup_test_environment
setup_test_environment()
import logging
from django.test import Client
records = []
class Collect(logging.Handler):
def emit(self, record):
records.append(record)
logging.getLogger('django.security').handlers = [Collect()]
response = Client().get('/orders/', headers={'Host': 'evil.example'})
print('status:', response.status_code)
for record in records:
print(f'{record.name} {record.levelname}: {record.getMessage()}')
The request was rejected and the record was written by the child logger of django.security.
$ uv run python -m demos.security status: 400 django.security.DisallowedHost ERROR: Invalid HTTP_HOST header: 'evil.example'. You may need to add 'evil.example' to ALLOWED_HOSTS.
Testing logging
The method assertLogs(logger, level) of TestCase collects the records of a logger in a context manager and fails if there are none; assertNoLogs() asserts the opposite. The result has the formatted lines in output and the records in records, from which one can read
the extra attributes, such as order_id and the status_code of the request logger. The tests are run with the option --buffer, which hides the output of the passing tests, here the log lines of the console handler, and shows the output of a test that fails.
import logging
from django.test import TestCase
from .models import Order
class LoggingTests(TestCase):
@classmethod
def setUpTestData(cls):
cls.order = Order.objects.create(customer='Alice', total='59.90')
def test_info_message(self):
with self.assertLogs('orders.views', level='INFO') as captured:
self.client.get(f'/orders/{self.order.pk}/')
self.assertEqual(captured.output,
[f'INFO:orders.views:Order {self.order.pk} has the total 59.90'])
def test_warning_for_a_missing_order(self):
with self.assertLogs('orders.views', level='WARNING') as captured:
response = self.client.get('/orders/999/')
self.assertEqual(response.status_code, 404)
self.assertEqual(captured.records[0].getMessage(), 'Order 999 does not exist')
def test_exception_is_logged_with_extra_data(self):
with self.assertLogs('orders.views', level='ERROR') as captured:
response = self.client.get(f'/orders/{self.order.pk}/pay/')
self.assertEqual(response.status_code, 502)
record = captured.records[0]
self.assertEqual(record.order_id, self.order.pk)
self.assertEqual(record.exc_info[0].__name__, 'PaymentError')
def test_request_logger_records_the_status(self):
with self.assertLogs('django.request', level='WARNING') as captured:
self.client.get('/orders/999/')
self.assertEqual(captured.records[0].status_code, 404)
def test_nothing_is_logged_for_the_index_at_warning_level(self):
with self.assertNoLogs('orders', level='WARNING'):
self.client.get('/orders/')
def test_request_id_header(self):
response = self.client.get('/orders/', headers={'X-Request-ID': 'abc'})
self.assertEqual(response['X-Request-ID'], 'abc')
All six tests pass.
$ uv run python manage.py test --buffer Found 6 test(s). Creating test database for alias 'default'... System check identified no issues (0 silenced). ...... ---------------------------------------------------------------------- Ran 6 tests in 0.016s OK Destroying test database for alias 'default'...
Source
Python documentation: Configuration dictionary schema
Python documentation: Logging Cookbook
In this article we have written log messages in views, configured handlers, formatters, and filters with LOGGING, added the request identifier to the records, written JSON lines to a rotating file, looked at the loggers
django.request, django.db.backends, and django.security, sent errors to the administrators, and tested the output. Related articles are
Django middleware, Django email, and
Django query performance.
Author
List all Django tutorials.