You called flash('Profile updated!'), redirected the user, and the next page loads without a trace of the message. No exception, no traceback — just a feature that quietly doesn’t work. That silence is exactly what makes this one annoying to debug.
Flash messages live in the session, so if your session isn't persisting, your messages won't either. First check that
app.secret_key (or SECRET_KEY) is actually set — without it, Flask can't sign the session cookie and flash() silently does nothing. Second, confirm your base template actually calls {{ get_flashed_messages() }} and that every child template extending it calls {{ super() }} inside any block that wraps that call. Those two things account for the vast majority of "my flash messages disappeared" reports.
How Flash Messages Actually Work
Under the hood, flash() doesn’t do anything magical — it appends a (category, message) tuple to a list stored in session['_flashes']. That’s it. There’s no separate flash storage engine, no cookie of its own, nothing beyond the same session Flask already uses for login state and CSRF tokens.
That detail matters because it means flash messages inherit every failure mode your session already has. If the session can’t be written, can’t be read back, or gets swapped out for a different session between the flash() call and the page render, the message is gone before you ever get to get_flashed_messages().
The other key detail: get_flashed_messages() pops the messages out of the session the moment it’s called. That’s by design — a flash message is meant to be shown exactly once — but it also means calling it twice in the same request (once in a macro, once in the parent template, for example) will only show the message the first time. The second call gets an empty list.
Diagnostic Steps
Before chasing any specific cause, confirm the basics in this order:
- Open your browser’s dev tools, check the Application → Cookies tab, and verify a
sessioncookie is actually being set after the request that callsflash(). - Add a quick
print(session.get('_flashes'))right before yourredirect()call to confirm the message actually landed in the session. - Check the same thing on the next request — if
_flashesis there before the redirect but gone after, the session isn’t surviving the round trip. - Search your base template for
get_flashed_messages— it’s shockingly common to have aflash()call in the Python code with no matching template code at all.
Once you know whether the message ever makes it into the session, and whether it’s still there on the following request, you’ll know which cause below to focus on.
Cause #1: Missing or Rotating SECRET_KEY
Flask signs session cookies with SECRET_KEY so it can trust their contents without storing session data server-side. If that key is missing, unset, or changes between requests, Flask can’t verify the cookie it gets back — so it just treats the incoming request as having no session at all.
# This code triggers the problem:
from flask import Flask, flash, redirect, url_for
app = Flask(__name__)
# No SECRET_KEY set!
@app.route('/save', methods=['POST'])
def save():
flash('Saved successfully!')
return redirect(url_for('index'))
# What you'll see (often in the console, not the browser):
RuntimeError: The session is unavailable because no secret key
was set. Set the secret_key on the application to something unique
and secret.
If you’re lucky, Flask raises that RuntimeError loudly. If you’re not — say, SESSION_TYPE is configured through an extension that swallows the error, or you’re running in an environment where app.secret_key gets set to a different random value on every worker restart — you get the worse version of this bug: it works locally, then silently fails in production because each Gunicorn worker generated its own random key at startup.
# Here's the fix:
import os
from flask import Flask
app = Flask(__name__)
app.secret_key = os.environ['FLASK_SECRET_KEY'] # fixed, persistent value
Generate that value once with python -c "import secrets; print(secrets.token_hex(32))", store it in your environment configuration, and never let it be randomly generated per-process. If you’re running behind Gunicorn with multiple workers, this is the single most common reason flash messages “work sometimes” — a request handled by worker A sets the session, and the redirect gets handled by worker B with a different key, so the cookie fails to verify.
Cause #2: The Template Never Actually Renders the Messages
This sounds too obvious to be the answer, but it’s the second most common cause by a wide margin — especially in projects using Blueprints or a base-layout pattern where the flash block lives in a file nobody’s looked at in months.
<!-- base.html — missing the flash block entirely -->
<!DOCTYPE html>
<html>
<body>
<nav>...</nav>
{% block content %}{% endblock %}
</body>
</html>
If base.html never calls get_flashed_messages(), it doesn’t matter how correctly your Python code calls flash() — there’s nowhere for the message to appear. Add it once, near the top of the body, so every page that extends the base template gets it for free:
<!-- base.html — fixed -->
<!DOCTYPE html>
<html>
<body>
<nav>...</nav>
{% with messages = get_flashed_messages(with_categories=True) %}
{% if messages %}
<ul class="flashes">
{% for category, message in messages %}
<li class="flash flash-{{ category }}">{{ message }}</li>
{% endfor %}
</ul>
{% endif %}
{% endwith %}
{% block content %}{% endblock %}
</body>
</html>
Two things trip people up in that snippet even after they’ve added it. First, with_categories=True changes the return shape from a flat list of strings to a list of (category, message) tuples — if you forget the flag but still unpack two values in the loop, you’ll get a ValueError: not enough values to unpack. Second, if a child template overrides {% block content %} and the flash block happens to live inside that block in some other template (instead of the shared base), overriding the block without calling {{ super() }} will drop the flash markup on that specific page even though it works everywhere else.
Cause #3: The Message Gets Consumed Before It’s Displayed
Because get_flashed_messages() pops messages out of the session, calling it more than once per request-response cycle means only the first caller sees anything.
# Somewhere in a context processor or macro:
@app.context_processor
def inject_flash_count():
# This pops the messages just to count them!
return {'flash_count': len(get_flashed_messages())}
<!-- Then later in the same render: -->
{% for category, message in get_flashed_messages(with_categories=True) %}
<!-- Always empty — the context processor already consumed them -->
{% endfor %}
The fix is to only ever call get_flashed_messages() in exactly one place per render, and let everything else read from that result instead of calling it again:
@app.context_processor
def inject_flash_count():
# Peek without consuming: cache messages on the request, don't call get_flashed_messages() here
return {}
{% with messages = get_flashed_messages(with_categories=True) %}
<span class="badge">{{ messages|length }}</span>
{% for category, message in messages %}
<li class="flash-{{ category }}">{{ message }}</li>
{% endfor %}
{% endwith %}
Storing the result once in a {% with %} block and reusing that variable is the safest pattern — it guarantees exactly one pop per request no matter how many places in the template want to look at the messages.
Cause #4: AJAX/Fetch Requests Don’t Trigger a Full Page Render
If your form submits via fetch() or XMLHttpRequest instead of a normal browser form post, the flow looks different, and it breaks the assumption flash messages depend on: a full page render right after the redirect.
// This "works" on the server but shows nothing to the user:
fetch('/save', { method: 'POST' })
.then(response => {
// response was a redirect, fetch already followed it and
// discarded the flashed message before any of your JS ran
});
flash() assumes the next request is a browser navigation that re-renders your base template — the one with get_flashed_messages() in it. When JavaScript intercepts the response instead, either the redirect gets auto-followed with no template rendering step your code controls, or you .json() the response and never touch HTML at all. Either way, the message was already popped from the session server-side (if the redirected-to route rendered anything at all) and your JS never displayed it.
For AJAX-driven UIs, don’t route success/error feedback through flash() at all — return it directly in the JSON response and render it client-side:
@app.route('/save', methods=['POST'])
def save():
# do the save...
return jsonify({'message': 'Saved successfully!', 'category': 'success'})
fetch('/save', { method: 'POST' })
.then(response => response.json())
.then(data => showToast(data.message, data.category));
If you do need a mixed approach — some clients get full page loads, some get AJAX — branch on request.headers.get('X-Requested-With') == 'XMLHttpRequest' (or a custom header you control) and only call flash() on the non-AJAX path.
Still Not Working?
If you’ve checked all four causes above and messages are still vanishing, look at these less common culprits:
SESSION_COOKIE_SAMESITEset to'Strict'combined with a cross-site redirect (common with certain OAuth or payment provider callback flows) can cause the browser to withhold the cookie on the follow-up request entirely.- A reverse proxy stripping
Set-Cookieheaders — check your Nginx or load balancer config if the app works fine when run directly but loses sessions behind the proxy. - Flask-Session or another server-side session backend misconfigured — if you’ve swapped the default cookie-based session for Redis- or filesystem-backed sessions, verify the backend connection is actually live; a silently failing Redis connection can make sessions behave as if they reset on every request. If you’re already dealing with Redis in your stack, it’s worth confirming that connection independently rather than assuming it’s fine.
- Testing with
curlor Postman without a cookie jar — each request starts a brand-new session unless you explicitly persist cookies between calls (curl -c cookies.txt -b cookies.txt ...), which makes flash messages look broken when the app is actually fine.
This overlaps with a broader class of session bugs — if you’re also seeing the request context itself behave unexpectedly outside of a normal view function, our guide to Flask’s request context errors covers a related set of gotchas. And if the flash message in question is reporting a login failure, see our Flask-Login current_user guide for the session pitfalls specific to authentication flows.
Summary Checklist
- [ ]
SECRET_KEYis set to a fixed, persistent value — not randomly generated per worker - [ ] Your base template calls
get_flashed_messages()at least once - [ ]
get_flashed_messages()is only called once per render (store the result with{% with %}) - [ ]
with_categories=Trueis used consistently if you’re unpacking tuples in the loop - [ ] Child templates call
{{ super() }}if they override a block containing the flash markup - [ ] AJAX/fetch-driven forms handle feedback in the JSON response, not through
flash() - [ ] Session cookies are actually round-tripping — check dev tools, not just assumptions
- [ ] Any reverse proxy in front of the app isn’t stripping
Set-Cookieheaders
Flash messages are one of those features that work by convention rather than by strict contract — Flask trusts you to store the session properly, render the template correctly, and call get_flashed_messages() exactly the right number of times. Get any one of those wrong and the failure is total silence rather than an exception, which is exactly why it’s worth working through this checklist methodically instead of guessing.
If a related session or request error does throw a real traceback, use Debugly’s trace formatter to quickly parse and analyze the Python traceback and pinpoint exactly where your session handling went wrong.