Almost every web app needs to tell the user what just happened: "Profile saved", "Invalid login", "Your trial ends in 3 days". Django ships a small, batteries-included tool for exactly this — the messages framework. It stores short, one-time flash messages on one request and shows them on the next, then clears them automatically.
This guide is current for Django 5.x and covers the full API: adding messages, levels, storage backends, mapping levels to CSS classes, displaying messages in templates and class-based views, plus a quick note on HTMX. If you are new to Django templating, the Django template engine basics make a good companion read.
What is the messages framework?
The messages framework solves the post/redirect/get (PRG) problem. After a successful POST you normally redirect so the user can't resubmit by refreshing — but a plain redirect throws away any context about what happened. Messages bridge that gap: you attach a message during the POST, redirect, and the message survives into the next GET response, where the template renders it once and discards it.
Each message has:
- text — what the user sees.
- a level —
DEBUG,INFO,SUCCESS,WARNINGorERROR. - tags — space-separated, CSS-friendly strings derived from the level plus any
extra_tagsyou pass.
Default setup
When you run django-admin startproject, the framework is already wired up — you rarely touch settings. For reference, these are the three pieces that must be present:
django.contrib.messagesinINSTALLED_APPS.django.contrib.sessions.middleware.SessionMiddlewareanddjango.contrib.messages.middleware.MessageMiddlewareinMIDDLEWARE.django.contrib.messages.context_processors.messagesin thecontext_processorsof yourTEMPLATESbackend — this is what exposesmessagesto every template.
# settings.py
INSTALLED_APPS = [
# ...
"django.contrib.messages",
]
MIDDLEWARE = [
# ...
"django.contrib.sessions.middleware.SessionMiddleware",
"django.contrib.messages.middleware.MessageMiddleware",
]
TEMPLATES = [
{
"BACKEND": "django.template.backends.django.DjangoTemplates",
"OPTIONS": {
"context_processors": [
# ...
"django.contrib.messages.context_processors.messages",
],
},
},
]Adding messages in a view
The quickest way is the level-named shortcuts. Each takes the request and the message text:
from django.contrib import messages
def my_view(request):
messages.debug(request, "%s SQL statements were executed." % count)
messages.info(request, "Three credits remain in your account.")
messages.success(request, "Profile details updated.")
messages.warning(request, "Your account expires in three days.")
messages.error(request, "Document deleted.")Under the hood every shortcut calls add_message(), which you can also use directly — handy for a dynamic level or a custom one:
from django.contrib import messages
# add_message(request, level, message, extra_tags="", fail_silently=False)
messages.add_message(request, messages.SUCCESS, "Order placed.", extra_tags="toast")Two optional arguments matter:
extra_tags— extra space-separated strings appended to the message'stags. Use them as CSS hooks (e.g."toast","dismissible").fail_silently=True— don't raiseMessageFailureif the middleware/storage isn't configured. Useful when adding messages from code that may run outside a normal request, such as management commands or tests.
Message levels and MESSAGE_LEVEL
Levels are just integers, so they can be filtered. Anything below MESSAGE_LEVEL is silently dropped. The default is INFO (20), which is why messages.debug() produces nothing in a fresh project until you lower the threshold.
# Built-in levels (django.contrib.messages.constants)
DEBUG = 10
INFO = 20
SUCCESS = 25
WARNING = 30
ERROR = 40
# settings.py - show DEBUG messages in development
from django.contrib.messages import constants as message_constants
MESSAGE_LEVEL = message_constants.DEBUG # or just the integer 10Storage backends
Where are messages kept between the two requests? That's the storage backend, chosen with MESSAGE_STORAGE.
FallbackStorage(default) — writes to a signed cookie first and automatically falls back to the session for messages too large to fit in the ~4 KB cookie. Best of both worlds; leave it alone unless you have a reason.CookieStorage— cookie only. No server-side state, but limited size and the data rides on every request.SessionStorage— session only. No size limit, but requiresdjango.contrib.sessionsand server-side session storage.
Switch backends with a single setting:
# settings.py
MESSAGE_STORAGE = "django.contrib.messages.storage.session.SessionStorage"
# Default is: "django.contrib.messages.storage.fallback.FallbackStorage"Mapping levels to CSS classes with MESSAGE_TAGS
By default each level renders a matching tag string (error, success, ...). CSS frameworks often expect different names — Bootstrap and many Tailwind setups use danger for errors. Remap level → tag once in settings and your templates stay clean:
# settings.py
from django.contrib.messages import constants as messages
MESSAGE_TAGS = {
messages.DEBUG: "secondary",
messages.INFO: "info",
messages.SUCCESS: "success",
messages.WARNING: "warning",
messages.ERROR: "danger", # was "error"
}Displaying messages in templates
The context processor exposes a messages iterable to every template. Loop over it once — iterating marks the messages as read so they won't reappear. Each message stringifies to its text and carries message.tags and message.level.
Here is a reusable Tailwind block; drop it in your base layout so every page shows flash messages. The in test keeps it robust even when extra_tags are present:
{% if messages %}
<div class="space-y-2">
{% for message in messages %}
<div class="rounded-md px-4 py-3 text-sm
{% if 'success' in message.tags %}bg-green-100 text-green-800
{% elif 'danger' in message.tags or 'error' in message.tags %}bg-red-100 text-red-800
{% elif 'warning' in message.tags %}bg-yellow-100 text-yellow-800
{% else %}bg-blue-100 text-blue-800{% endif %}">
{{ message }}
</div>
{% endfor %}
</div>
{% endif %}If you prefer numeric comparisons over tag strings, the context also provides DEFAULT_MESSAGE_LEVELS, e.g. {% if message.level == DEFAULT_MESSAGE_LEVELS.ERROR %}.
Using messages with class-based views
With function-based views you add messages inline. For class-based views, the framework ships SuccessMessageMixin, which adds a message automatically when a form validates (build the form itself with Django's forms API). You can also drop down to messages.success() inside form_valid() for full control. Note the redirect — the message is added before the HttpResponseRedirect, then rendered after the browser follows it (the PRG pattern):
from django.contrib import messages
from django.contrib.messages.views import SuccessMessageMixin
from django.urls import reverse_lazy
from django.views.generic import CreateView, UpdateView
from myapp.forms import StudentForm
from myapp.models import Student
class StudentCreateView(SuccessMessageMixin, CreateView):
model = Student
form_class = StudentForm
success_url = reverse_lazy("student_list")
# %(name)s pulls from the submitted form's cleaned data
success_message = "%(name)s was created successfully."
class StudentUpdateView(UpdateView):
model = Student
form_class = StudentForm
success_url = reverse_lazy("student_list")
def form_valid(self, form):
response = super().form_valid(form)
messages.success(self.request, f"{self.object.name} was updated.")
return responseRendering messages in HTMX / AJAX flows
With HTMX a partial response replaces only a fragment of the page, so a global messages block in the base template never re-renders. The clean fix is an out-of-band (OOB) swap: include the messages partial in the response and mark it hx-swap-oob="true", so HTMX patches it into a fixed container wherever that lives on the page.
Keep a <div id="messages"></div> in your base layout, then return this partial alongside your main fragment:
<!-- messages.html -->
<div id="messages" hx-swap-oob="true">
{% for message in messages %}
<div class="rounded-md px-4 py-3 text-sm {{ message.tags }}">{{ message }}</div>
{% endfor %}
</div>Because iterating messages consumes them, the partial both displays the current flash messages and clears the storage in the same response — exactly the behaviour you want for an AJAX update. For deeper template work, see custom template tags and filters.
Need a hand wiring notifications, forms or HTMX into a production Django app? Our Django development services team can help.
Frequently Asked Questions
Why aren't my messages showing up?
The usual causes are: the messages context processor is missing from TEMPLATES; your view added a message but re-rendered instead of redirecting, so the next request never ran; or something earlier in the page already iterated messages and consumed them. Also confirm MESSAGE_LEVEL isn't filtering out the level you used.
Do messages persist across a redirect?
Yes — that's the whole point. A message added during a POST is stored, survives the redirect, and is rendered on the following GET, then cleared. This is the post/redirect/get pattern.
How long do messages last?
They are one-time flash messages. They live until the first response that iterates over them, then they are deleted. They are not a persistent notification system; for an inbox-style feed, store notifications in your own model.
What's the difference between the storage backends?
FallbackStorage (the default) uses a signed cookie and spills over to the session when messages exceed the ~4 KB cookie limit. CookieStorage is cookie-only (no server state, size-limited). SessionStorage is session-only (no size limit, needs server-side sessions). Most apps should keep the default.
How do I change the CSS class for errors (for example to Bootstrap's danger)?
Set MESSAGE_TAGS in settings to remap a level to a different tag string, e.g. {messages.ERROR: "danger"}. The new string then appears in message.tags in your template.
Can I add messages outside a view?
You can call messages.add_message() anywhere you have access to the request. In code paths that may run without configured message storage (management commands, some tests), pass fail_silently=True so it won't raise MessageFailure.