ZetCode

Django messages framework

last modified November 13, 2026 Python 3.15

After a user saves a form or deletes a record, the application should say what happened. The page that shows the result is often not the one that handled the request, because a good view redirects after a POST. The messages framework solves this: a view puts a one-time notification into a store, and the next page that renders messages displays it and removes it. In this article we add messages to a small contacts application, show them in a template, use levels and tags, look at where the messages are stored, and test them. The examples use Django 6.1 and Python 3.15.

How messages work

The framework is a part of django.contrib.messages, which the project template enables: the app is in INSTALLED_APPS, MessageMiddleware is in MIDDLEWARE, and the context processor django.contrib.messages.context_processors.messages adds the variable messages to every template that is rendered with a request. A message has a level, an integer, the text, and optional extra tags. There are five levels, DEBUG, INFO, SUCCESS, WARNING, and ERROR, and a helper function for each, such as messages.success(request, text). The messages that are added while a request is handled are saved when the response is created, and loaded and deleted when a template iterates over messages. Because the storage is attached to the visitor, not to the page, it works with a redirect.

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 contacts, and create the folder demos for demo scripts.

$ mkdir messages
$ cd messages
$ uv init --bare
$ uv add django
$ uv run django-admin startproject config .
$ uv run python manage.py startapp contacts
$ mkdir demos

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.

config/settings.py
INSTALLED_APPS = [
    'django.contrib.admin',
    'django.contrib.auth',
    'django.contrib.contenttypes',
    'django.contrib.sessions',
    'django.contrib.messages',
    'django.contrib.staticfiles',
    'contacts',
]

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 app contacts manages a list of contacts. The model has a name and an email address. We generate and apply its migration.

contacts/models.py
from django.db import models


class Contact(models.Model):
    name = models.CharField(max_length=60)
    email = models.EmailField()

    class Meta:
        ordering = ['name']

    def __str__(self):
        return self.name

The model has only two fields and is ordered by name. The migration is created and applied with the usual commands.

$ uv run python manage.py makemigrations contacts
Migrations for 'contacts':
  contacts/migrations/0001_initial.py
    + Create model Contact
$ uv run python manage.py migrate | tail -n 2
  Applying contacts.0001_initial... OK
  Applying sessions.0001_initial... OK

Adding messages in views

The function messages.success(request, text) and its relatives take the request, which carries the storage. For the generic views there is the mixin SuccessMessageMixin, which adds a success message after a valid form; the attribute success_message is a format string that receives the cleaned data of the form, so %(name)s becomes the name of the contact. The mixin must be listed before the view class. DeleteView has no form data to format, so the delete view adds its warning by hand in form_valid(), and the create view reports an invalid form with messages.error(). The view levels adds one message of each level, a message with extra tags, and a message with a custom level, so that we can see how they are displayed.

contacts/views.py
from django.contrib import messages
from django.contrib.messages.views import SuccessMessageMixin
from django.shortcuts import redirect
from django.urls import reverse_lazy
from django.views.generic import CreateView, DeleteView, ListView, UpdateView

from .models import Contact

CRITICAL = 35


class ContactList(ListView):
    model = Contact


class ContactCreate(SuccessMessageMixin, CreateView):
    model = Contact
    fields = ['name', 'email']
    success_url = reverse_lazy('contacts:list')
    success_message = '%(name)s was added'

    def form_invalid(self, form):
        messages.error(self.request, 'The contact was not saved')
        return super().form_invalid(form)


class ContactUpdate(SuccessMessageMixin, UpdateView):
    model = Contact
    fields = ['name', 'email']
    success_url = reverse_lazy('contacts:list')
    success_message = '%(name)s was updated'


class ContactDelete(DeleteView):
    model = Contact
    success_url = reverse_lazy('contacts:list')

    def form_valid(self, form):
        messages.warning(self.request, f'{self.object.name} was deleted')
        return super().form_valid(form)


def levels(request):
    messages.debug(request, 'Debug messages are dropped by default')
    messages.info(request, 'Information for the user')
    messages.success(request, 'The operation succeeded')
    messages.warning(request, 'Take care')
    messages.error(request, 'Something failed', extra_tags='sticky')
    messages.add_message(request, CRITICAL, 'The disk is almost full')
    return redirect('contacts:list')

The levels have numbers, and a custom level is any integer; we use 35, between WARNING and ERROR, and call add_message(), which the helper functions use as well. The extra tags are appended to the tags of the level.

contacts/urls.py
from django.urls import path

from . import views

app_name = 'contacts'

urlpatterns = [
    path('', views.ContactList.as_view(), name='list'),
    path('add/', views.ContactCreate.as_view(), name='add'),
    path('<int:pk>/edit/', views.ContactUpdate.as_view(), name='edit'),
    path('<int:pk>/delete/', views.ContactDelete.as_view(), name='delete'),
    path('levels/', views.levels, name='levels'),
]

The URL patterns have the namespace contacts; the app is included in the root URL configuration.

config/urls.py
from django.urls import include, path

urlpatterns = [
    path('contacts/', include('contacts.urls')),
]

The generic views need the templates contact_list.html, contact_form.html, and contact_confirm_delete.html in the folder contacts/templates/contacts, which are found by the naming conventions of the generic views. They all extend a base template, where we render the messages.

Displaying messages

The template iterates over messages. Each message converts to its text; the attribute message.tags is the string of the extra tags and the tag of the level, which we use as a CSS class; message.level_tag is only the tag of the level, and message.level the number. The text is escaped like any other variable, so the text of a message may carry the user's input safely. Iterating over the messages marks them as used, and they are deleted at the end of the response, even if the template shows only some of them.

contacts/templates/contacts/base.html
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <title>Contacts</title>
</head>
<body>
  <h1>Contacts</h1>
  {% if messages %}
  <ul class="messages">
    {% for message in messages %}
    <li class="{{ message.tags }}">{{ message }}</li>
    {% endfor %}
  </ul>
  {% endif %}
  {% block content %}{% endblock %}
</body>
</html>

The list page and the two forms extend the base template. The list shows the contacts with the links to the edit and delete pages.

contacts/templates/contacts/contact_list.html
{% extends 'contacts/base.html' %}

{% block content %}
<ul>
  {% for contact in object_list %}
  <li>
    {{ contact.name }} &lt;{{ contact.email }}&gt;
    <a href="{% url 'contacts:edit' contact.pk %}">Edit</a>
    <a href="{% url 'contacts:delete' contact.pk %}">Delete</a>
  </li>
  {% empty %}
  <li>No contacts yet.</li>
  {% endfor %}
</ul>
<p><a href="{% url 'contacts:add' %}">Add contact</a></p>
{% endblock %}

The form page is used for both adding and editing, and the delete page asks for a confirmation.

contacts/templates/contacts/contact_form.html
{% extends 'contacts/base.html' %}

{% block content %}
<form method="post">
  {% csrf_token %}
  {{ form.as_p }}
  <button type="submit">Save</button>
</form>
{% endblock %}

The page for the confirmation of the deletion contains a form that posts to the same URL.

contacts/templates/contacts/contact_confirm_delete.html
{% extends 'contacts/base.html' %}

{% block content %}
<form method="post">
  {% csrf_token %}
  <p>Delete {{ object.name }}?</p>
  <button type="submit">Delete</button>
</form>
{% endblock %}

The three templates are all that the views need. The only part which is specific to the messages is the base template.

Levels and tags

The tags are the names that we can use for styling. By default the levels have the tags debug, info, success, warning, and error. A CSS framework may use other names; Bootstrap, for example, calls the error alerts danger. The setting MESSAGE_TAGS maps levels to tags; it also adds a tag for our own level.

config/settings.py (message tags)
from django.contrib.messages import constants as message_constants

...

MESSAGE_TAGS = {
    message_constants.ERROR: 'danger',
    35: 'critical',
}

The setting MESSAGE_LEVEL is the minimum level that is recorded. It defaults to INFO, so the messages of the level DEBUG are dropped when they are added. We can lower it in the settings or change it for a request with messages.set_level(). The script prints the constants and the tags, and then visits the view that adds the messages, following the redirect by hand.

demos/levels.py
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 re

from django.conf import settings
from django.contrib.messages import constants
from django.contrib.messages.utils import get_level_tags

print('constants:', {name: getattr(constants, name) for name in
                     ('DEBUG', 'INFO', 'SUCCESS', 'WARNING', 'ERROR')})
print('MESSAGE_LEVEL set in the project:', hasattr(settings, 'MESSAGE_LEVEL'))
print('tags:', get_level_tags())
print()


def show(html):
    items = re.findall(r'<li class="([^"]*)">(.*?)</li>', html)
    for tags, text in items:
        print(f'  [{tags}] {text}')
    if not items:
        print('  (no messages)')


client = Client()
response = client.get('/contacts/levels/')
print('GET /contacts/levels/:', response.status_code, response['Location'])
print('the first page after the redirect:')
show(client.get('/contacts/').content.decode())
print('the second page:')
show(client.get('/contacts/').content.decode())

The debug message did not reach the list, because its level is below the minimum. The error has the tag danger, with the extra tag sticky in front of it, and the custom level 35 has the tag from our map. The messages are displayed once: the second request for the same page shows nothing.

$ uv run python -m demos.levels
constants: {'DEBUG': 10, 'INFO': 20, 'SUCCESS': 25, 'WARNING': 30, 'ERROR': 40}
MESSAGE_LEVEL set in the project: False
tags: {10: 'debug', 20: 'info', 25: 'success', 30: 'warning', 40: 'danger', 35: 'critical'}

GET /contacts/levels/: 302 /contacts/
the first page after the redirect:
  [info] Information for the user
  [success] The operation succeeded
  [warning] Take care
  [sticky danger] Something failed
  [critical] The disk is almost full
the second page:
  (no messages)

Messages in action

The script uses the test client to go through the cycle. It posts an invalid form, whose error message is shown in the same response because no redirect takes place; then a valid form, which redirects with the success message waiting in a cookie, the page after the redirect, and a reload. It also updates and deletes a contact, and adds a contact with HTML in the name. At the end it switches the storage backend, which is explained in the next section.

demos/flow.py
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 re

from django.test import override_settings

from contacts.models import Contact

Contact.objects.all().delete()


def show(label, response):
    html = response.content.decode()
    items = re.findall(r'<li class="([^"]*)">(.*?)</li>', html.split('</ul>')[0]) \
        if 'class="messages"' in html else []
    print(f'{label}: {response.status_code}', response.get('Location', ''), [f'[{t}] {m}' for t, m in items])


client = Client()
show('invalid form', client.post('/contacts/add/', {'name': '', 'email': 'nope'}))
response = client.post('/contacts/add/', {'name': 'Alice', 'email': 'alice@example.com'})
show('valid form', response)
print('cookies:', list(response.cookies))
show('after the redirect', client.get(response['Location']))
show('reload', client.get('/contacts/'))

alice = Contact.objects.get()
show('update', client.post(f'/contacts/{alice.pk}/edit/', {'name': 'Alice B.', 'email': 'alice@example.com'}))
show('page', client.get('/contacts/'))
show('delete', client.post(f'/contacts/{alice.pk}/delete/'))
show('page', client.get('/contacts/'))

client.post('/contacts/add/', {'name': '<b>Bob</b>', 'email': 'bob@example.com'})
html = client.get('/contacts/').content.decode()
print(re.findall(r'<li class="success">.*?</li>', html))

print()
with override_settings(MESSAGE_STORAGE='django.contrib.messages.storage.session.SessionStorage'):
    client = Client()
    response = client.post('/contacts/add/', {'name': 'Carol', 'email': 'carol@example.com'})
    print('session storage, cookies:', list(response.cookies))
    print('session keys:', sorted(client.session.keys()))
    show('page', client.get('/contacts/'))
    print('session keys after display:', sorted(client.session.keys()))
Contact.objects.all().delete()

The invalid form gets the error immediately, and nothing is stored. The valid form stores the message in the cookie messages, which the redirected page reads, shows, and removes. The reload has no messages. The same happens after the update and the delete. The HTML in the name is escaped. With the session storage no cookie named messages is set, and the data goes to the session under the key _messages and disappears after it is displayed.

$ uv run python -m demos.flow
invalid form: 200  ['[danger] The contact was not saved']
valid form: 302 /contacts/ []
cookies: ['messages']
after the redirect: 200  ['[success] Alice was added']
reload: 200  []
update: 302 /contacts/ []
page: 200  ['[success] Alice B. was updated']
delete: 302 /contacts/ []
page: 200  ['[warning] Alice B. was deleted']
['<li class="success">&lt;b&gt;Bob&lt;/b&gt; was added</li>']

session storage, cookies: ['sessionid']
session keys: ['_messages']
page: 200  ['[success] Carol was added']
session keys after display: []

Storage backends

The setting MESSAGE_STORAGE selects the storage. CookieStorage keeps the messages in a signed cookie, so it needs no server state, but a cookie cannot hold more than about 2 KB. SessionStorage keeps them in the session, which needs SessionMiddleware (see Django sessions and cookies). The default, FallbackStorage, tries the cookie first and stores the messages that do not fit in the session. Calling a message function in a project without the middleware raises MessageFailure, unless we pass fail_silently=True, which helps in reusable code.

demos/storage.py
import os

import django

os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings')
django.setup()

from django.contrib import messages
from hashlib import sha256

from django.contrib.messages.storage.cookie import CookieStorage, MessageDecoder
from django.contrib.messages.storage.fallback import FallbackStorage
from django.contrib.sessions.backends.db import SessionStore
from django.http import HttpResponse
from django.test import RequestFactory

factory = RequestFactory()

request = factory.get('/')
try:
    messages.info(request, 'Hello')
except messages.MessageFailure as error:
    print('without the middleware:', error)
messages.info(request, 'Hello', fail_silently=True)
print('with fail_silently=True: ignored')
print()


def storage_with(texts):
    request = factory.get('/')
    request.session = SessionStore()
    storage = FallbackStorage(request)
    for text in texts:
        storage.add(messages.INFO, text)
    response = HttpResponse()
    storage.update(response)
    return request, response


request, response = storage_with(['Saved', 'Sent'])
print('two short messages')
print('  cookie:', response.cookies['messages'].value[:60] + '...')
print('  attributes:', {key: value for key, value in response.cookies['messages'].items() if value})
print('  session:', request.session.get('_messages'))

texts = [''.join(sha256(f'{number}-{part}'.encode()).hexdigest() for part in range(5))
         for number in range(20)]
request, response = storage_with(texts)
in_session = MessageDecoder().decode(request.session['_messages'])
print('twenty messages of', len(texts[0]), 'characters')
print('  cookie size:', len(response.cookies['messages'].value), 'characters')
print('  messages in the session:', len(in_session))

reader = factory.get('/')
reader.COOKIES['messages'] = response.cookies['messages'].value
reader.session = request.session
loaded = [message.message for message in FallbackStorage(reader)]
print('  messages read back:', len(loaded), '| same order:', loaded == texts)
print('  limit of the cookie:', CookieStorage.max_cookie_size, 'bytes')

The script first shows the failure without the middleware and the option that silences it. Then it saves two short messages: they go into the cookie, which is compressed and signed, has the flag HttpOnly, and nothing is written to the session. With twenty longer messages the cookie is filled up to its limit, and the rest, thirteen messages, goes to the session. Reading them back through a new storage gives all twenty messages in the original order.

$ uv run python -m demos.storage
without the middleware: You cannot add messages without installing django.contrib.messages.middleware.MessageMiddleware
with fail_silently=True: ignored

two short messages
  cookie: .eJyLjlaKj88qzs-Lz00tLk5MT1XSMdAxMtBRCk4sS01R0lFSitXBqSQ1rwS...
  attributes: {'path': '/', 'httponly': True, 'samesite': 'Lax'}
  session: None
twenty messages of 320 characters
  cookie size: 1864 characters
  messages in the session: 13
  messages read back: 20 | same order: True
  limit of the cookie: 2048 bytes

Testing messages

The test client keeps the cookie between requests, so the tests can follow the redirect. The class MessagesTestMixin from django.contrib.messages.test provides assertMessages(), which compares the messages of the response with a list of Message objects. Messages that were not read stay in the storage, so each test checks the response of the request that added them. In the test of the levels we follow the redirect and read the tags from the context of the final response.

contacts/tests.py
from django.contrib.messages import constants
from django.contrib.messages.storage.base import Message
from django.contrib.messages.test import MessagesTestMixin
from django.test import TestCase
from django.urls import reverse

from .models import Contact


class MessageTests(MessagesTestMixin, TestCase):

    def test_create_adds_a_success_message(self):
        response = self.client.post(reverse('contacts:add'),
                                    {'name': 'Alice', 'email': 'alice@example.com'})
        self.assertRedirects(response, reverse('contacts:list'))
        self.assertMessages(response, [Message(constants.SUCCESS, 'Alice was added')])

    def test_invalid_form_adds_an_error_message(self):
        response = self.client.post(reverse('contacts:add'), {'name': '', 'email': 'x'})
        self.assertEqual(response.status_code, 200)
        self.assertMessages(response, [Message(constants.ERROR, 'The contact was not saved')])
        self.assertContains(response, 'class="danger"')

    def test_message_is_shown_once(self):
        self.client.post(reverse('contacts:add'), {'name': 'Alice', 'email': 'alice@example.com'})
        first = self.client.get(reverse('contacts:list'))
        second = self.client.get(reverse('contacts:list'))
        self.assertContains(first, 'Alice was added')
        self.assertNotContains(second, 'Alice was added')

    def test_update_adds_a_success_message(self):
        contact = Contact.objects.create(name='Bob', email='bob@example.com')
        response = self.client.post(reverse('contacts:edit', args=[contact.pk]),
                                    {'name': 'Bobby', 'email': 'bob@example.com'})
        self.assertMessages(response, [Message(constants.SUCCESS, 'Bobby was updated')])

    def test_delete_adds_a_warning(self):
        contact = Contact.objects.create(name='Bob', email='bob@example.com')
        response = self.client.post(reverse('contacts:delete', args=[contact.pk]))
        self.assertMessages(response, [Message(constants.WARNING, 'Bob was deleted')])

    def test_levels(self):
        response = self.client.get(reverse('contacts:levels'), follow=True)
        tags = [message.tags for message in response.context['messages']]
        self.assertEqual(tags, ['info', 'success', 'warning', 'sticky danger', 'critical'])

    def test_message_text_is_escaped(self):
        self.client.post(reverse('contacts:add'), {'name': '<b>x</b>', 'email': 'x@example.com'})
        response = self.client.get(reverse('contacts:list'))
        self.assertContains(response, '&lt;b&gt;x&lt;/b&gt; was added')

All seven tests pass. The test of the invalid form also checks that the template renders the custom tag as a class.

$ uv run python manage.py test
Found 7 test(s).
Creating test database for alias 'default'...
System check identified no issues (0 silenced).
.......
----------------------------------------------------------------------
Ran 7 tests in 0.042s

OK
Destroying test database for alias 'default'...

Source

Django documentation: The messages framework

Django documentation: Messages settings

Django documentation: SuccessMessageMixin

In this article we have added success, warning, and error messages to the views, displayed them in a template, used custom levels and tags, compared the storage backends, and tested the messages. Related articles are Django class-based views, Django sessions and cookies, and Django forms.

Author

My name is Jan Bodnar, and I am a passionate programmer with extensive programming experience. I have been writing programming articles since 2007. To date, I have authored over 1,400 articles and 8 e-books. I possess more than ten years of experience in teaching programming.

List all Django tutorials.