30 Jun 2026
Django community aggregator: Community blog posts
200ms Β± 500ms
I once needed the SLA for an endpoint my dashboard leaned on, so I asked the team that owned it. Their lead came back with 200ms Β± 500ms. Read that literally and the fastest responses arrive 300ms before the request is even sent. The number wasn't malicious - it came straight out of the standard formulas. The formulas were wrong for the data, and that mistake is everywhere.

30 Jun 2026 10:00am GMT
28 Jun 2026
Django community aggregator: Community blog posts
Maintaining a mature Open Source project: dealing with the upgrade treadmill with the help of a LLM
Maintaining a mature, reasonably-popular Django open-source is boring. Here I explore using a LLM to automate away some of the boring work.
28 Jun 2026 3:00am GMT
26 Jun 2026
Django community aggregator: Community blog posts
Open Source Comes From People
I recently attended my first PG Data 2026 conference where keynote speaker Robert Haas delivered a talk that has stayed with me. His keynote focused on the people behind PostgreSQL, the growing challenges of sustaining open-source communities, and the urgent need to cultivate new contributors through mentorship and community engagement. While his remarks centered on PostgreSQL, they sparked broader reflections for me about the future of open source and communities like Django.
26 Jun 2026 7:00pm GMT
Issue 343: Django 6.1 beta 1 released
News
Django 6.1 beta 1 released
Django 6.1 beta 1 is now available, giving the community a chance to test upcoming features and improvements before the final release on August 5.
Djangonaut Space: Launching Contributors
Djangonaut Space shares the results from its first six mentorship sessions, showing how an 8-week cohort program helped launch 104 contributors from 40+ countries into long-term open source participation and leadership.
Django Software Foundation
How the Django Software Foundation Became a CNA
Learn how the Django Software Foundation became a CVE Numbering Authority, giving it the ability to assign CVE IDs directly and streamline Django's security advisory process.
Wagtail CMS News
Wagtail as Django admin on steroids
Think Wagtail is just a CMS? See why it can serve as a polished, modern replacement for Django's admin with a familiar API and powerful features that make client-facing backends shine.
Comparing open weight AI models and providers
Open weight AI models are closing the gap with proprietary LLMs, and this guide explains how to compare models and providers on performance, cost, energy use, and transparency.
Releases
Python 3.15.0 beta 3 is here!
Python 3.15 beta 3 is out with nearly 200 bug fixes plus major additions like lazy imports, frozendict, sentinel objects, a faster JIT, and UTF-8 as the default encoding.
Updates to Django
Today, "Updates to Django" is presented by Raffaella from Djangonaut Space! π
Last week we had 24 pull requests merged into Django by 16 different contributors - including 2 first-time contributors! Congratulations to Margaret Fero and diaxoaine for having their first commits merged into Django - welcome on board!
EmailMessage.message()now raises aValueErrorifBccis included in theheadersargument orextra_headersattribute. Use thebccargument instead. #37152- Calling
QuerySet.aiterator()afterprefetch_related()without providing achunk_sizeis deprecated. It currently falls back to achunk_sizeof 2000, but aValueErrorwill be raised in Django 7.1. #37143
Articles
Teach your linter your own rules
boa-restrictor is a Python/Django linter that now lets you register your own AST-based rule classes via pyproject.toml to enforce project-specific conventions. This is especially useful as a deterministic guardrail for keeping AI coding agents from repeating unwanted patterns.
Why I wrote PEP 832 -- virtual environment discovery
PEP 832 proposes a standard way for editors and AI tools to discover Python virtual environments, aiming to make project setup smoother regardless of your workflow tool.
Supporting Django's Next Chapter
Caktus Group has become a founding sponsor of the Django Software Foundation's new Executive Director position, investing in Django's long term sustainability and encouraging other companies to do the same.
Mitigated API authentication bypass for python.org download metadata
Python.org has disclosed and mitigated an authentication bypass that could have altered download metadata, with no evidence of exploitation after extensive audits and additional security hardening.
How I Architected Automatic Parking Detection in Django - Bluetooth Disconnects, Geofence Events, and a Strict State Machine
A deep dive into building a reliable Django parking detection system using Bluetooth events, geofencing, state machines, and optimistic locking to safely handle concurrency.
What I learned from two days of hanging out with AI experts
Five practical takeaways from an AI conference suggest the future belongs to model agnosticism, measurable ROI, and smaller open models instead of hype.
Videos
Learning Python in the Age of AI
In this short interview from PyCon US, Sheena O'Connell discusses one of the biggest questions facing developers today: how should people learn Python in the age of AI?
Paolo Melchiorre on AI-Assisted Development
Another PyCon US 2026 chat, this time with Paolo Melchiorre talking about Django, AI-assisted development, open-source maintainership, and how the Python community is adapting to AI.
Django Forum
Django 6.1 release - timeline and next steps
Notes and updates from Fellow Jacob Walls on the 6.1 release process.
Adding database backend methods to get hardcoded or nonexistent primary key values for tests
From Tim Graham, surfacing ticket #37175 "to see what our creative community can suggest."
Django Fellow Reports
Jacob Walls
Tended to a flurry of fixes before the non-release-blocker bugfix freeze for Django 6.1 in a few days. Also chipped away at some performance improvements for ASGI projects using sync middleware.
Natalia Bidart
Lots of preparation for the upcoming 6.1 Ξ²eta, with the goal of stabilizing recent changes and ensuring overall readiness π. I also spent time digging into Django's async behavior, reviewing recent changes and following through on related optimizations and documentation updates π. I also looked more closely at packaging and reproducibility, especially around artifact builds, to improve our consistency in the release process π¦.
Django Job Board
Senior Python/Django Developer at Gryps
Founding ML/Data Scientist (Remote, UK) at MyDataValue
Projects
vintasoftware/django-ai-boost
A MCP server for Django applications, inspired by Laravel Boost.
Archmonger/ServeStatic
Production-grade Python static file server. Run as middleware or standalone.
26 Jun 2026 3:00pm GMT
24 Jun 2026
Django community aggregator: Community blog posts
Supporting Django's Next Chapter
The path to hiring an Executive Director gained real momentum at DjangoCon US 2024, when Jacob Kaplan-Moss shared a vision for what dedicated resources could mean for the future of Django. In his blog post If We Had $1,000,000, he invited companies and supporters to help get the initiative off the ground. The response from the community was inspiring, and we're proud to see that vision become reality.
24 Jun 2026 7:00pm GMT
Wagtail as Django admin on steroids
Many of you have probably heard of Wagtail CMS, but not everyone knows that Wagtail, in a nutshell, is a supercharged admin backend for Django. At least that's how I see it, and how I often pitch it to fellow Django developers.
Django comes with its own django.contrib.admin β¦
24 Jun 2026 9:38am GMT
23 Jun 2026
Django community aggregator: Community blog posts
Boolean algebra
The third article in the series, still on conditions. The previous installment was about their shape - merging ifs, factoring shared decisions, dropping checks that earn nothing. This one reaches for the other lever: the algebra of the conditions themselves - not a textbook tour, just the handful of transformations I lean on in everyday code.

23 Jun 2026 12:00pm GMT
19 Jun 2026
Django community aggregator: Community blog posts
Issue 342: DSF Executive Director Search
## News
Announcing the Search for a DSF Executive Director
The Django Software Foundation is hiring its first Executive Director, and we have the Django community to thank for making it possible.
Six Django web development agencies have jointly pledged $47,500 to help fund the Executive Director's first year: Caktus Group, Lincoln Loop, Six Feet Up, Cuttlesoft, OddBird, and Two Rock. This is the financial foundation we needed to move from "we should hire an ED someday" to "we are hiring an ED now."
I'm delighted to rejoin the Sovereign Tech Fellowship
Hugo van Kemenade returns to the Sovereign Tech Fellowship after being one of six participants in the 2025 pilot, calling out how dedicated time helped ship Python 3.14 and 3.15 releases, mentor triagers, and improve release automation and accessibility. The post also tracks a wide set of community and governance work, and looks ahead to a larger 2026 cohort spanning maintainers, community managers, and technical writers.
Python Software Foundation
Python Software Foundation News: PSF Board Election Dates for 2026
PSF Board elections for 2026 open for nominations on July 28 (2:00 pm UTC) and voting runs September 1 to September 15, with voter affirmation due August 25. The Packaging Council election will run in parallel under PEP 772, and PSF member voting eligibility is handled via psfmember.org.
Updates to Django
Last week we had 24! pull requests merged into Django by 11 different contributors.
This week's Django highlights π¦:
-
Added --using option to sendtestemail management command. (#37141)
-
As a performance optimization, add an option to cull the DBCache only on every n queries. (#32785)
-
Reduced false positives in strings during collectstatic. (#36969, #35371)
Sponsored Link
Reach 4,300+ Engaged Django Developers
Sponsor this newsletter to reach an active community of Python and Django developers.
Articles
In search of a new contribution model
Carlton Gibson on why open source's contribution model is broken--burnout, extractive contributions, harassment, and now AI--and his plans to experiment with something less open-by-default on newer projects.
The University In The AI Era
From Carson Gross, creator of HTMX and full-time college professor, a detailed and practical look at what AI means for universities in general and computer science programs in particular.
How I Work From Anywhere Without Losing My Place
Jeff has been running a new remote dev setup that allows for seamless switching between home office, an iPad, or even a phone when out on the go.
LLM-Inspired Development
How a bad idea from an LLM led to a good idea on a website.
Tech doesn't matter? Why to use Django for agentic coding
Ronny Vedrilla argues that in the age of agentic coding, Django's opinionated structure, secure-by-default posture, and heavy representation in training data make it an ideal "harness" that keeps AI agents on the rails-not a competitive edge, but a hedge against shipping a quiet disaster.
Videos
The Modern Python Web Stack: Django, FastAPI, uv, Pydantic, and AI
A 5-minute conversation from PyCon US with Jeff Triplett on how Python web development is changing fast. (Yes, this video features Jeff and Will, the two authors of this newsletter, but we still think it warrants mention! π€)
Podcasts
Teaching Python #158: Will Vincent on Django, AI Coding, and Why Fundamentals Still Matter
A chat on why Django continues to matter, the reality behind vibe coding, local AI models, and more.
Django Forum
Call for mentors - GSoC 2026 with Django!
Google Summer of Code is around the corner and there is still a need for mentors on some projects.
Ticket 34753, Document how to properly escape to in email messages
An active discussion around this particular issue. Checking the forum is a great way to get a pulse on what's happening with core Django development.
Django Fellow Reports
Jacob Walls
In this four-day week (I headed out Friday for a college reunion), everything got a little bit better. First, check out @blighj's estimate showing that collectstatic's import statement detection reliability (needed to rewrite URLs) improves in Django 6.1 from 88% to 99%. Meanwhile @felixxm is stress-testing database defaults and landing fixes needed for using Django 6.1's UUID4()/UUID7() functions. Finally, we made the test client more friendly for third-party permission packages like django-guardian and django-rules. @sage also spotted a breakage in DRF in the upcoming Django 6.1 beta, since Wagtail tests against Django's main branch. I expect the fix to land before the beta is even out. Be like wagtail and test main!
Natalia Bidart
This week had a bit of a reset feel to it π§Ή. After the previous stretch of PyCon US, security prep, and the security release itself π, I spent time going through pending and snoozed items β°, trying to close loops and get things back to a more manageable state.
We also reviewed and triaged a batch of security reports π that were shared by a major AI company, following conversations I had at PyCon US π ποΈ about the growing volume of LLM-generated security submissions and the challenges they create for OSS projects (Django in particular). The reports were generated using an advanced security-focused model π€ against the Django codebase. We evaluated each finding, confirming and addressing valid issues where appropriate and mapping others to existing tickets and prior reports. Overall, Django is in good shape πͺ, as the results largely overlapped with known reports, validated our current triage approach, and reinforced confidence in our security stance π.
Events
Django Girls Krakow on 18th July 2026
This event is taking place during EuroPython at the sprints venue.
Django Day Copenhagen 2026
Djangonauts from in and around Denmark are meeting up for the second edition of Django Day Copenhagen 2026, October 2.
International Travel to DjangoCon US 2026
Are you attending DjangoCon US 2026 in Chicago, Illinois, but you are not from US and need some travel information? Here are some things to consider when planning your trip.
Join DEFNA! There's a seat on the DEFNA board open
Django Events Foundation North America (DEFNA) is looking for another board member. We have eight board members currently and are looking for another person passionate about growing the DjangoCon US community to join.
Django Job Board
Senior Python/Django Developer at Gryps π
Founding ML/Data Scientist (Remote, UK) at MyDataValue
Projects
ranahaani/GNews
A Happy and lightweight Python Package that Provides an API to search for articles on Google News and returns a JSON response.
jazzband/django-newsletter
An email newsletter application for the Django web application framework, including an extended admin interface, web (un)subscription, dynamic e-mail templates, an archive and HTML email support.
19 Jun 2026 3:00pm GMT
17 Jun 2026
Django community aggregator: Community blog posts
The 2026 way of using importmaps in Django
The 2026 way of using importmaps in Django
I last wrote about Django, JavaScript modules and importmaps in May 2025, slightly over a year ago.
The main topic of this post is the django-js-asset 4.0 release. The library is used in many places, some of the more well-known packages using it are django-mptt and django-ckeditor. I have since done a lot of work evolving the ways of integrating importmaps but the efforts to standardize upon an approach have stalled a bit. The main reason for this, apart from time and energy, was that I wasn't really all that happy with the global importmap. When I had only a few modules using the importmap facility, I didn't care all that much. Now that the recently released django-content-editor 9.0 also uses importmaps for shipping a refactored, much more modular JavaScript implementation while still keeping all the benefits of cache busting using ManifestStaticFilesStorage1, having a global importmap got annoying. The content editor JavaScript is only used within the Django administration interface, but when using a single global importmap object, the importmap entries were always there on each page that used an importmap at all.
A better solution was needed. I'm a big fan of using forms.Media for collecting CSS and JavaScript from widgets, forms and utilities. It helps me avoid inline JavaScript since at least 2017. I'm not using it for site-wide CSS and JavaScript, I'm still transpiling, PostCSS-ing and bundling the assets using rspack as for example written about here and here.
Why importmaps?
A quick refresher on why this matters at all. Django's ManifestStaticFilesStorage hashes the contents of each file into its name for cache busting, but out of the box it doesn't rewrite the import statements inside JavaScript modules. Importmaps bridge the gap: your code imports a stable name:
import { initializeEditors } from "django-prose-editor/editor"
and the importmap tells the browser where that name actually lives:
<script type="importmap">
{"imports": {
"django-prose-editor/editor": "/static/django_prose_editor/editor.6e8dd4c12e2e.js"
}}
</script>
So the import stays clean and constant while the file behind it can get a new hash on every deploy.
django-js-asset 4.0
The updated django-js-asset 4.0 doesn't ship the old, global importmap at all. This means the upgrade might require some work. Instead of one importmap shared across the whole site, you now get a specific importmap assembled for the context at hand - either by Django itself when it collects the media of your forms, widgets and the admin, or explicitly by you in a view or context processor. The building block in both cases is the ImportMap object; when it travels through js_asset.Media (a subclass of django.forms.Media) the maps are automatically merged into a single <script type="importmap">, by customizing and extending what Django does already when merging media instances.
The release notes go into more detail.
In practice
If you're using a package such as django-prose-editor in the Django admin you don't have to do anything, things should just work.
If you're using such a package outside the admin, you have to remove "js_asset.context_processors.importmap" from your list of context processors. On one particular website the prose editor is the only package with importmap entries outside the admin, so I have to add the importmap to the template context myself:
from django_prose_editor.widgets import importmap
def view(request, ...):
return render(request, "template.html", {
# ...
"importmap": importmap,
})
The template then just renders it in the <head>:
... {{ importmap }}</head>
On a different site, I have a slightly more involved scenario where I previously used importmap.update(...) to add my own entries to the importmap. There, I'm using a custom context processor to always add these entries to the importmap too:
from django_prose_editor.widgets import importmap as dpe_importmap
from js_asset import ImportMap, static_lazy
_site_importmap = ImportMap({
"imports": {
"my-module": static_lazy("my-module.js"),
}
})
_importmap = dpe_importmap | _site_importmap
def importmap(request):
return {"importmap": _importmap}
This importmap is merged once at server startup and then served repeatedly to the client. Because we use the lazy version of the static function we can do this during startup and not worry about files not yet collected by collectstatic - we'll get the correct paths later.
On the same site as the previous example, I also have an admin inline which requires some JavaScript and also an importmap:
from django.contrib import admin
from django.forms import Script
from js_asset import Media, ImportMap
# Initializing this once. Not necessary but I like it better that way.
_importmap = ImportMap({
"imports": {
# ...
}
})
class ModelInline(admin.StackedInline):
@property
def media(self):
return Media(
js=[
_importmap,
Script("module.js", type="module"),
]
)
As of 4.0, JS and CSS produce Django's own Script and Stylesheet objects, so you can import and use Script directly from django.forms as shown above (on Django 4.2-5.1, import it from js_asset instead, which backports it). The familiar JS("module.js", {"type": "module"}) wrapper still works unchanged if you prefer it - it just takes a positional dict instead of keyword arguments.
Here, it's really important to use the js_asset.Media and not django.forms.Media. js_asset.Media knows how to handle importmaps - all importmaps are collected from all media lists, merged and added to the output before all other CSS and especially JavaScript. The reason for that is that browsers only honour a single importmap per page, and it really has to appear before all JavaScript modules referencing any entries in the importmap.
The nice thing about js_asset.Media is that it doesn't have to appear first in the list of media classes which are merged - it can also appear in the middle or last, and still can do its magic after all Media objects have been merged into a single one.
The rest is handled by Django itself, since it already supports collecting media assets. The missing piece was just the importmap object and the js_asset.Media class which knows how to special case them, and which - through the power of overriding __add__ and __radd__ takes over all the other media instances.
What's next
I haven't yet used CSP nonces using {% csp_nonce_attr media %} in production myself, but it should just work, even with importmaps and everything else. Given that I have a passing test suite I have no reason to believe it doesn't already work, but I'd like to have a confirmation.
I'm hoping to standardize some more. If we could get something like this in Django core that would be really nice. Maybe I'll be able to work on that at Django on the Med ποΈ. Since no browser supports multiple importmaps as of today having multiple implementations of importmaps in the Django ecosystem will lead to trouble down the road. I think there is a clear case to be made for importmap support in Django and I would obviously love it if the approach implemented today in django-js-asset would be the basis for the official solution.
-
Without having to do any overrides to enable ESM support. β©
17 Jun 2026 5:00pm GMT
16 Jun 2026
Django community aggregator: Community blog posts
Cheating as a programming discipline
Great programmers cheat. A hard problem gets quietly swapped for an easier one; a transaction-grade database is replaced by a flat file nobody misses; machinery everyone else considers mandatory simply never gets built. They know a lot - and that's exactly why they get away with it.

16 Jun 2026 11:00am GMT
15 Jun 2026
Django community aggregator: Community blog posts
LLM Inspired Development
How Claude inadvertently suggested new features for my personal site.
15 Jun 2026 3:28pm GMT
14 Jun 2026
Django community aggregator: Community blog posts
Understanding Memory Usage in Django Webserver Workers

If you are coming from the PHP world, you might be used to thinking that when a request reaches the web server, everything is parsed and processed from scratch. In Python, however, the behavior is a little different.
A Python web server (for example, Gunicorn) starts one or more worker processes and then continuously accepts and processes requests as a running server application. In this article, I will explore how memory is managed in that environment.
Startup time vs. request execution
When you run a Django web server (either the development server or a Gunicorn-based deployment), there are two phases of execution: startup, when the server application is initialized, and the request/response cycle, when a request is received and processed to return a response.
During startup, wsgi.py and get_wsgi_application() are executed once per worker at boot time, Django settings are evaluated, applications listed in INSTALLED_APPS are imported and registered, ready() methods of app configs are called, and URL patterns are compiled.
During request execution, the middleware chain processes the request, the URL is resolved, the view is executed, context managers run within the view, the template is rendered, the middleware chain processes the response, and the response is returned.
The Django development server runs a single worker process but uses threads to handle concurrent requests. Gunicorn runs multiple worker processes (separate copies of the server application). A common rule of thumb for Gunicorn configuration is (2 Γ number_of_CPU_cores) + 1 workers.
Little demo of shared memory between requests
If you have mutable globals in your Django code, they will be shared within the same worker process, even across threads.
Here's a simple example for demonstration purposes (note that this is an antipattern and should never be used in production):
from django.http import JsonResponse
from django.utils.timezone import now
TIMESTAMPS = []
def show_timestamps(request):
TIMESTAMPS.append(now())
return JsonResponse({"timestamps": TIMESTAMPS})
The output would be (manually indented for readability):
{"timestamps": [
"2026-06-08T19:48:42.044Z",
"2026-06-08T19:48:43.875Z",
"2026-06-08T19:48:44.776Z"
]}
If you serve this application with Gunicorn using multiple workers and open the view in several tabs, refreshing them a few times, you will see that the timestamp list keeps growing. However, because each request may be served by a different worker, the timestamps returned by each worker will differ.
How RAM is managed
1. Compilation/setup state is persistent
When Django starts and the application server is initialized, the following objects live in process memory for the lifetime of the worker process:
django.apps.registry.Apps(the app registry with all models)- URL resolvers (
urlpatterns) - Middleware instances (if instantiated at startup)
- Cached template engines
- Database connection configurations (not connections themselves)
This memory normally remains allocated until the worker process restarts.
2. Execution state lives per-request
Each request creates objects that are local to that request's call stack-views, forms, querysets, serializers, and so on. Once the response is sent:
- Python's garbage collector normally reclaims those objects.
- There is no explicit Django cleanup; Django relies on Python's normal memory management.
- Request-scoped objects are cleaned up only after they go out of scope and no references to them remain.
Common bugs to avoid
1. Mutable module-level state
A mutable object defined at the module level persists across all requests handled by that worker process and will accumulate data from every user.
β BAD:
_recent_users = []
def dashboard(request):
# leaks across requests!
_recent_users.append(request.user.id)
return render(
request,
"dashboard.html",
{"recent": _recent_users}
)
β GOOD - state belongs in the DB, cache, or session:
def dashboard(request):
recent_users = get_recent_users_from_db()
return render(
request,
"dashboard.html",
{"recent": recent_users}
)
2. Storing per-request data on a shared instance
Middleware instances live for the lifetime of the worker process. Storing anything request-specific on self means it will be shared across every request handled by that worker.
β BAD:
class AuditMiddleware:
def __init__(self, get_response):
self.get_response = get_response
# shared across all requests!
self.current_user = None
def __call__(self, request):
# User A's data visible when User B's request runs
self.current_user = request.user
return self.get_response(request)
β GOOD - attach data to the request object, which is scoped to a single request:
class AuditMiddleware:
def __init__(self, get_response):
# only immutable config on self
self.get_response = get_response
def __call__(self, request):
# scoped to this request's lifetime
request.current_user = request.user
return self.get_response(request)
3. Class-level cache shared across instances
A class attribute is shared across all instances of that class and therefore across all requests. The first user's data may be cached and returned to everyone else.
β BAD:
class ReportGenerator:
# class attribute, shared across all instances and all requests!
_cache = {}
def get_data(self, user):
if "data" not in self._cache:
# first user's data cached for everyone
self._cache["data"] = expensive_query(user)
return self._cache["data"]
β GOOD - use the cache framework with a user-scoped key:
from django.core.cache import cache
class ReportGenerator:
def get_data(self, user):
key = f"report:{user.id}"
return cache.get_or_set(
key,
lambda: expensive_query(user),
timeout=300,
)
Mental model
Before writing module-level code, ask yourself:
"If 1000 different users hit this worker, and this variable exists for all of them, is that safe?"
| Location | Lifetime | Safe for user data? |
|---|---|---|
| Local variable in a view/function | Single request | β Yes |
request object attributes |
Single request | β Yes |
threading.local() |
Single thread | β Yes |
| Database / cache with scoped keys | Persistent but keyed | β Yes |
self on a middleware/class instance |
Worker lifetime | β οΈ Only immutable config |
| Module-level mutable variable | Worker lifetime | β Never user data |
| Class-level mutable attribute | Worker lifetime | β Never user data |
Rule of thumb: immutable configuration (numbers, booleans, strings, bytes, tuples, frozensets, and None) belongs at module level; anything that varies by request, user, or time belongs in the request cycle.
Conclusion
A Django worker process has two separate memory areas: a startup state that lives for the lifetime of the worker process (app registry, URL resolvers, middleware instances, template engines) and a request state that exists only while handling a request (views, querysets, forms) and is reclaimed by Python's garbage collector afterward.
The main rule is that immutable configuration can live at the module level, but anything that depends on the current user or request should stay inside the request cycle, such as local variables or the request object.
That's because module-level variables, class attributes, and middleware instance state are shared by all requests handled by the same worker, making them unsafe for user-specific data and a common cause of memory leaks and data exposure bugs.
Cover Picture by Jon Tyson
14 Jun 2026 5:00pm GMT
12 Jun 2026
Django community aggregator: Community blog posts
Issue 341: Django 2026 Fundraising Goals
News
DSF 2026 Fundraising Goals
The Django Software Foundation is raising its 2026 annual fundraising goal from $300,000 to $500,000. The money supports the Django Fellows program, legal and trademark work, community grants and events, ongoing infrastructure, and progress toward hiring an Executive Director.
Announcing Our DjangoCon US 2026 Talks!
DjangoCon US 2026 released its tutorial and talk lineup for Aug 24 to Aug 26, with live access for online-only ticket holders and free YouTube uploads after the conference. Expect sessions spanning Django 6.1 and modern ORM patterns, performance testing, Wagtail routing, Postgres updates, and deployment topics, with the final schedule to follow soon.
Vulnerability and malware checks in uv
uv introduces uv audit to scan locked dependencies for known vulnerabilities and adverse statuses, positioned as a faster uv-native alternative to pip-audit.
Updates to Django
Today, "Updates to Django" is presented by Hwayoung from Djangonaut Space! π
Last week we had 16 pull requests merged into Django by 11 different contributors - including 5 first-time contributors! Congratulations to jodizzle, Bankai, Chris Rose, esperonus-karolis and Wes P. for having their first commits merged into Django - welcome on board!
This week's Django highlights: π¦
- Made DjangoJSONEncoder consistently omit the .000 fractional-second suffix when serializing datetime and time values that have no meaningful microseconds. (#37108)
- Added a new listurls management command to display all registered URL patterns in a Django project. π (#28800)
- Limited the number of related objects shown in inline formset validation error messages to prevent excessively long error output. (#36984)
- Changed SIGNED_COOKIE_LEGACY_SALT_FALLBACK to default to False and began deprecating the transitional compatibility setting. (CVE-2026-6873)
Releases
Python 3.14.6 and 3.13.14 are now available!
Python 3.14.6 is out as the sixth 3.14 maintenance release, with about 179 bugfixes plus build and documentation updates since 3.14.5. Python 3.13.14 follows as the fourteenth 3.13 maintenance release, adding around 240 bugfixes along with build and documentation changes since 3.13.13.
Core Dispatch #5
Python 3.15.0 beta 2 landed June 2, with another round of milestones due June 9 and June 23. Expect the practical stuff in 3.15 including a fixed O(n^2) blowup in unicodedata.normalize, XML multi-byte encoding support, and fresh deprecation warnings around ast and abc abstract* helpers, alongside an initial documented Python security policy in the Devguide.
Sponsored Link
Django middleware composes request handlers. Harnesses do the same for AI agents - Claude Code, Codex, Gemini in one coordinated system. Learn what a harness actually is, why it's a new primitive, and how to engineer one that holds in production. Apache 2.0, open source.

Articles
My Local LLM Setup - Fast Agentic Development with No Token Bill
From Peter Grandstaff, a write-up of his maxxed-out local LLM setup using an Nvidia GeForce RTX 4090 video card. A very cool setup and one which many of us will likely be using some version of in the future.
Logical optimizations
Nested if blocks can often be rewritten as a single conjunction, but only when each if fully owns the body with no else or trailing code.
Browser Push Notifications for a Django Website
Set up browser push notifications end to end: store push subscriptions in Django, register/unregister via authenticated endpoints, and send notifications from a Huey background task using pywebpush with VAPID.
Anything new?
Maintainer Matthias Kestenholz explains why stepping away from django-mptt is hard, and how entitlement in issue trackers turns "free labor" into a burnout trap.
PyCon US 2026 - Open Source Community in Long Beach - Peter Grandstaff
Another one from Peter Grandstaff, a day-by-day PyCon US 2026 trip focused on building Django connections, sponsorship work for DjangoCon US, and catching talks on developer experience and open source community support.
Django Forum
Switch to Playwright tests for integration testing
This project aims to modernize Django's integration testing by introducing Playwright as an alternative to Selenium.
Proposal: Leverage Oracle Test Pilot for Django CI
A proposal from the Oracle Test Pilot for Third-Party Software program building Oracle Test Pilot to provide access to the latest versions of the Oracle databases to Django on GitHub, for free.
Django Fellow Reports
Natalia Bidart
This week was quite intense, with most of the focus π on getting the security release out the door πͺ. Issuing the release for the 5 CVEs took a fair amount of coordination and attention to detail, and definitely consumed a good chunk of brain power π§ β‘.
Alongside that, there were a number of meetings throughout the week, so overall it was a mix of high-focus release work and keeping in sync with the different groups π€. Bonus: the final DEP 0018 for MAILERS was approved, moved to the accepted folder, and merged β .
Jacob Walls
A highlight this week was landing the listurls command modeled on django-extensions. Many tickets triaged, reviewed, authored, and discussed.
Events
Announcing Our DjangoCon US 2026 Talks!
The complete lineup of talks, August 24-26, is now live! So many great talks coming up.
Django Meetup Vol. 78 / Beyond Boilerplate: Building Maintainable CRUD in Django
Django Meetup Cologne Vol. 78 will take place on the 16th of June 2026, online and in person.
Django Job Board
Founding ML/Data Scientist (Remote, UK) at MyDataValue π
Projects
django-helpdesk/django-helpdesk
A Django application to manage tickets for an internal helpdesk. Formerly known as Jutda Helpdesk.
emmett-framework/granian
A Rust HTTP server for Python applications.
12 Jun 2026 3:00pm GMT
10 Jun 2026
Django community aggregator: Community blog posts
Running Fallout London on Bazzite
I'm a huge Fallout fan, and Fallout London is one of the most impressive mods I've seen in years: a full DLC-sized expansion set in post-apocalyptic London, made by a community team. Running it on Bazzite (my gaming OS of choice) wasn't completely straightforward, so here's what actually worked for me. Consider this a note to future me, but hopefully it saves someone else an afternoon of trial and error.
What you'll need
- Bazzite installed on your machine
- Heroic Games Launcher
- Fallout 4 (the mod requires it as a base)
- The "Fallout London One Click Mod" (available through Heroic)
The steps
1. Install Heroic Games Launcher
If you don't have it yet, install Heroic from the Bazzite app store or via Flatpak. It's a fantastic open-source launcher that handles GOG, Epic, and Amazon games, and it plays very nicely with Proton.
After you install it, login with your GOG account

2. Install the Fallout London One Click Mod
Search for "Fallout London" in Heroic and install the One Click Mod version. This bundles everything together so you don't have to manually manage mod files. Let it do its thing.

3. Disable UMU (yes, it needs to be disabled)
This is the counterintuitive part. Once the mod is installed, go to its settings in Heroic, then the Advanced tab. You'll see an option called "Disable UMU". Enable it (meaning: check the checkbox to disable UMU). I know, "enable the disable" is a confusing way to phrase it, but that's what it says.
Without this, the game won't launch correctly on Bazzite.

4. Run it once in Desktop Mode
Before adding it to Steam, launch the game once directly from Heroic while you're in Desktop Mode. This lets everything install and configure properly: shaders, redistributables, the works. Wait until you're actually in the game and confirmed it runs without issues, then close it.
5. Add to Steam
Click the three-dot menu on the game in Heroic and select "Add to Steam". From this point on you can launch it from Game Mode like any other game in your library.

Play!
That's it. Boot into Game Mode, find Fallout London in your library, and enjoy one of the best Fallout experiences made outside of Bethesda.


See you in the next one!
10 Jun 2026 5:00am GMT
09 Jun 2026
Django community aggregator: Community blog posts
Logical optimizations
The second article in the series. The first was about control flow; this one stays with the same tactic - reshaping code - one layer down, at the condition. Here: merging ifs, factoring shared decisions, and dropping checks that earn nothing. The Boolean algebra of conditions - De Morgan and friends - is a different lever, and gets its own installment next time.

09 Jun 2026 11:00am GMT
05 Jun 2026
Django community aggregator: Community blog posts
Browser Push Notifications for a Django Website

For DjangoTricks, and some other websites, I intentionally didn't set email notifications when a feedback message arrived - I didn't want to pay for an email server or spam my inbox. While checking the messages in the database from time to time, sometimes I found out about them too late.
Last weekend, I decided to implement Web Push notifications to get notified about the feedback in my OS, just like in this example:

This tutorial walks through adding Web Push notifications to a Django project from scratch. When a visitor submits a feedback form the site owner receives a native browser notification - even if the admin tab is closed - thanks to a service worker and a Huey background task.
How it works
Push Notifications work in such a way: at first, people who want to get notifications need to subscribe to the notifications in their browser. The subscribers are stored on the push notification servers and also their identifiers are stored in Django website database. Whenever we need to send the messages to those subscribers, we send them to push notification server that passes the message to all subscribers if their browsers are open at the moment. If the browsers are closed at that moment, the message's TTL (time to live) in seconds set long enough, and the message is not expired yet, they will get the message later.
The push service depends on the browser:
- Chrome - Google's FCM (Firebase Cloud Messaging) -
fcm.googleapis.com - Firefox - Mozilla Autopush -
updates.push.services.mozilla.com - Safari - Apple Push Notification Service (APNs)

The two standard Web Push prerequisites are a VAPID key pair (identifies your server to the push service) and a service worker (a background JS script that receives the push and shows the notification even when the tab is closed).
The workflow would be as follows:
- I as a superuser visit the Django administration page that has sw.js file with a service worker and a JavaScript to subscribe to notidications.
- JavaScript request Notification permission. I accept it.
- JS calls
pushManager.subscribe()with the VAPID public key. - JS POSTs the subscription to
/notifications/push/subscribe/. - Django stores it in PushSubscription model.
Visitor submits feedback form
- A view saves
FeedbackMessageto the database. transaction.on_commitqueues a Huey task.- Huey task calls
pywebpush- browser's push service (FCM / Mozilla). - Push service wakes the service worker.
- Service worker shows a native OS notification.
- Clicking it opens the Django admin change page.
Prerequisites
- Django project using Huey for background tasks
- Python virtual environment
Install two dependencies:
(.venv)$ pip install pywebpush py-vapid
pywebpushis the library to communicate with the Push Notification server.py-vapidwill only be needed once to generate keys.
You'll need HTTPS to test the Web Push notifications if your host is not 127.0.0.1. If you use a custom domain in your /etc/hosts, such as djangotricks.localhost, you will also need to set up HTTPS with mkcert.
Step 1. The Feedback App
Create feedback app with FeedbackMessage model that will store submitted feedback messages:
# myproject/apps/feedback/models.py
from django.conf import settings
from django.db import models
from django.utils.translation import gettext_lazy as _
class FeedbackMessage(models.Model):
created_at = models.DateTimeField(_("Created at"), auto_now_add=True)
submitter_name = models.CharField(_("Submitter name"), max_length=200)
submitter_email = models.EmailField(_("Submitter email")
content = models.TextField(_("Content")
class Meta:
verbose_name = _("Feedback Message")
verbose_name_plural = _("Feedback Messages")
ordering = ("-created_at",)
def __str__(self):
return _("Feedback message from {}").format(self.submitter_name)
Create the form:
# myproject/apps/feedback/forms.py
from django import forms
from django.utils.translation import gettext_lazy as _
from .models import FeedbackMessage
class FeedbackMessageForm(forms.ModelForm):
class Meta:
model = FeedbackMessage
fields = [
"content",
"submitter_name",
"submitter_email",
]
widgets = {
"content": forms.Textarea(
attrs={
"rows": 5,
"placeholder": _("Your message")
}
),
}
labels = {
"submitter_name": _("Your name"),
"submitter_email": _("Your email"),
}
Create a view to handle that form:
# myproject/apps/feedback/views.py
from django.db import transaction
from django.shortcuts import render, redirect
from django.urls import reverse
from .forms import FeedbackMessageForm
from .tasks import send_feedback_push_notification
def feedback_form(request):
if request.method == "POST":
form = FeedbackMessageForm(data=request.POST)
if form.is_valid():
message = form.save(commit=False)
if request.user.is_authenticated:
message.user = request.user
message.save()
transaction.on_commit(
lambda: send_feedback_push_notification(message.pk)
)
return redirect(reverse("feedback:complete"))
else:
form = FeedbackMessageForm()
return render(request, "feedback/form.html", {"form": form})
def feedback_complete(request):
return render(request, "feedback/complete.html")
Create the app config, migrations, templates, URLs, and Django administration for it.
Step 2. VAPID keys
Generate the key pair once. These stay on the server and are never committed to version control.
(.venv)$ vapid --gen
(.venv)$ vapid --applicationServerKey --private-key private_key.pem
--gen writes private_key.pem and public_key.pem to the current directory.
The private_key.pem file will contain the key like:
-----BEGIN PRIVATE KEY-----
<Multiline private key data>
-----END PRIVATE KEY-----
--applicationServerKey prints the base64url-encoded public key the browser needs, such as:
Application Server Key = <Public key data as base64url>
For the secrets.json or .env file where you store your secrets, you will need the content of <Private key data with newlines removed> and <Public key data as base64url>.
{
...
"VAPID_PRIVATE_KEY": "<Private key data with newlines removed>",
"VAPID_PUBLIC_KEY": "<Public key data as base64url>"
}
Don't commit the *.pem files or the secrets to the Git repo!
Step 3. Django settings
# myproject/settings.py
### WEB PUSH ###
VAPID_PRIVATE_KEY = get_secret("VAPID_PRIVATE_KEY")
VAPID_PUBLIC_KEY = get_secret("VAPID_PUBLIC_KEY")
VAPID_ADMIN_EMAIL = "admin@mydomain.com"
Step 4. The Notifications App
Create notifications app with the PushSubscription model to track the Push notification subscribers:
# myproject/apps/notifications/models.py
from django.conf import settings
from django.db import models
from django.utils.translation import gettext_lazy as _
class PushSubscription(models.Model):
"""One browser push subscription for one device."""
created_at = models.DateTimeField(_("Created at"), auto_now_add=True)
user = models.ForeignKey(
_("User"),
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name="push_subscriptions",
)
endpoint = models.TextField(_("Endpoint"), unique=True)
p256dh = models.TextField(_("Browser ECDH public key"))
auth = models.TextField(_("16-byte auth secret"))
class Meta:
verbose_name = _("Push Subscription")
verbose_name_plural = _("Push Subscriptions")
ordering = ("-created_at",)
def __str__(self):
return f"{self.user} - {self.endpoint[:60]}"
Create app configuration, migrations, and Django administration for it.
Step 5. Subscribe / unsubscribe views
Create the views that will be called after the user subscribes or unsubscribes to Push notifications. Also a view for the service worker sw.js:
# myproject/apps/notifications/views.py
import json
from django.contrib.auth.decorators import login_required
from django.contrib.staticfiles import finders
from django.http import HttpResponse, JsonResponse
from django.views.decorators.http import require_POST
from .models import PushSubscription
@login_required
@require_POST
def push_subscribe(request):
try:
data = json.loads(request.body)
endpoint = data["endpoint"]
p256dh = data["keys"]["p256dh"]
auth = data["keys"]["auth"]
except (KeyError, json.JSONDecodeError):
return JsonResponse({"error": "Invalid subscription data"}, status=400)
PushSubscription.objects.update_or_create(
endpoint=endpoint,
defaults={"user": request.user, "p256dh": p256dh, "auth": auth},
)
return JsonResponse({"status": "subscribed"})
@login_required
@require_POST
def push_unsubscribe(request):
try:
endpoint = json.loads(request.body)["endpoint"]
except (KeyError, json.JSONDecodeError):
return JsonResponse(
{"error": "Invalid data"},
status=400
)
PushSubscription.objects.filter(
user=request.user,
endpoint=endpoint,
).delete()
return JsonResponse({"status": "unsubscribed"})
def service_worker(request):
"""Serve sw-feedback.js as sw.js at the admin root"""
path = finders.find("admin/js/sw-feedback.js")
with open(path) as fh:
content = fh.read()
return HttpResponse(
content,
content_type="application/javascript"
)
Step 6. Wire URLs into myproject/urls.py
Plug the notification views into URLs:
# myproject/apps/notifications/urls.py
from django.urls import path
from . import views
app_name = "notifications"
urlpatterns = [
path("push/subscribe/", views.push_subscribe, name="push_subscribe"),
path("push/unsubscribe/", views.push_unsubscribe, name="push_unsubscribe"),
]
The service worker URL must be mounted at the same prefix as ADMIN_URL so its scope covers the admin. Add both patterns before the admin.site.urls line:
# myproject/urls.py
from notifications import views as notifications_views
urlpatterns = [
# ...
path(
f"{settings.ADMIN_URL}sw.js",
notifications_views.service_worker,
name="admin_service_worker",
),
path(
"notifications/",
include("notifications.urls", namespace="notifications"),
),
path(settings.ADMIN_URL, admin.site.urls),
# ...
]
Step 7. Service worker JavaScript
Save as myproject/static/admin/js/sw-feedback.js:
self.addEventListener("push", function (event) {
const data = event.data ? event.data.json() : {};
const title = data.title || "New feedback message";
const options = {
body: data.body || "",
icon: "/static/admin/img/icon-yes.svg",
data: { url: data.url || "/" },
};
event.waitUntil(
self.registration.showNotification(title, options)
);
});
self.addEventListener("notificationclick", function (event) {
event.notification.close();
event.waitUntil(
clients.openWindow(event.notification.data.url)
);
});
Step 8. Admin subscription JavaScript
Save as myproject/static/admin/js/push-subscribe.js:
(function () {
"use strict";
// Injected by the Django template override in Part 9.
const VAPID_PUBLIC_KEY = window.VAPID_PUBLIC_KEY;
const SUBSCRIBE_URL = window.PUSH_SUBSCRIBE_URL;
const UNSUBSCRIBE_URL = window.PUSH_UNSUBSCRIBE_URL;
const ADMIN_URL = window.ADMIN_URL;
// Tracks the endpoint last registered on the server so we can delete it even
// when the browser subscription has already been silently revoked (e.g. user
// cleared site data or the push subscription expired without blocking).
const STORAGE_KEY = "pushSubscriptionEndpoint";
// Read the CSRF token from the hidden input injected by {% csrf_token %}.
// Do not use the cookie: this project has CSRF_USE_SESSIONS = True.
const CSRF_TOKEN = document.querySelector("[name=csrfmiddlewaretoken]")?.value || "";
function urlBase64ToUint8Array(base64String) {
const padding = "=".repeat((4 - (base64String.length % 4)) % 4);
const base64 = (base64String + padding).replace(/-/g, "+").replace(/_/g, "/");
const rawData = atob(base64);
return Uint8Array.from([...rawData].map((c) => c.charCodeAt(0)));
}
function post(url, body) {
return fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json", "X-CSRFToken": CSRF_TOKEN },
body: JSON.stringify(body),
});
}
async function syncSubscription() {
if (!VAPID_PUBLIC_KEY) return;
if (!("serviceWorker" in navigator) || !("PushManager" in window)) return;
const registration = await navigator.serviceWorker.register(
"/" + ADMIN_URL + "sw.js",
{ scope: "/" + ADMIN_URL }
);
await navigator.serviceWorker.ready;
const storedEndpoint = localStorage.getItem(STORAGE_KEY);
const existing = await registration.pushManager.getSubscription();
// The browser subscription was revoked (user blocked notifications, cleared
// site data, or the subscription expired) but the server record still exists
// - delete it using the endpoint we stored at subscription time.
if (storedEndpoint && (!existing || existing.endpoint !== storedEndpoint)) {
await post(UNSUBSCRIBE_URL, { endpoint: storedEndpoint });
localStorage.removeItem(STORAGE_KEY);
}
// Already subscribed and the server already knows about this endpoint.
if (existing && existing.endpoint === storedEndpoint) return;
const permission = await Notification.requestPermission();
if (permission !== "granted") return;
const subscription = await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(VAPID_PUBLIC_KEY),
});
await post(SUBSCRIBE_URL, subscription.toJSON());
localStorage.setItem(STORAGE_KEY, subscription.endpoint);
}
document.addEventListener("DOMContentLoaded", syncSubscription);
})();
localStorage is the key to reliable unsubscription. Notification.permission alone cannot detect silent revocations - when a user clears site data or a push subscription expires, the permission may still read "granted" while the browser-side subscription is gone. By storing the endpoint at subscribe time and comparing it on every page load, the script can call UNSUBSCRIBE_URL with the old endpoint even after the browser subscription object has disappeared.
Step 9. Context processor
A context processor injects the VAPID globals into every admin template response.
Create myproject/apps/notifications/context_processors.py:
from django.conf import settings
from django.urls import reverse
def push_notifications(request):
"""Inject push-notification globals into every admin page."""
if not request.path.startswith(f"/{settings.ADMIN_URL}"):
return {}
if not settings.VAPID_PUBLIC_KEY:
return {}
return {
"push_vapid_public_key": settings.VAPID_PUBLIC_KEY,
"push_subscribe_url": reverse("notifications:push_subscribe"),
"push_unsubscribe_url": reverse("notifications:push_unsubscribe"),
"push_admin_url": settings.ADMIN_URL,
}
Register it in the template settings:
TEMPLATES = [
{
"BACKEND": "django.template.backends.django.DjangoTemplates",
"DIRS": [
os.path.join(BASE_DIR, "myproject", "templates"),
],
"APP_DIRS": True,
"OPTIONS": {
"context_processors": [
# ...
"myproject.apps.notifications.context_processors.push_notification_settings",
],
},
},
]
Step 10. Inject globals via admin/base_site.html
Override myproject/templates/admin/base_site.html. If one already exists, add the extrahead block; otherwise create the file extending Django's built-in template:
{% extends "admin/base_site.html" %}
{% load static %}
{% block extrahead %}
{# ... any existing content such as a favicon include ... #}
{% if push_vapid_public_key %}
{% csrf_token %}
<script nonce="{{ request.csp_nonce }}">
window.VAPID_PUBLIC_KEY = "{{ push_vapid_public_key }}";
window.PUSH_SUBSCRIBE_URL = "{{ push_subscribe_url }}";
window.PUSH_UNSUBSCRIBE_URL = "{{ push_unsubscribe_url }}";
window.ADMIN_URL = "{{ push_admin_url }}";
</script>
<script src="{% static 'admin/js/push-subscribe.js' %}"></script>
{% endif %}
{% endblock %}
Step 11. Huey task
# myproject/apps/feedback/tasks.py
import json
from django.conf import settings
from django.urls import reverse
from huey.contrib.djhuey import db_task
@db_task()
def send_feedback_push_notification(feedback_message_id):
from pywebpush import webpush, WebPushException
from feedback.models import FeedbackMessage
from notifications.models import PushSubscription
private_key = settings.VAPID_PRIVATE_KEY
if not private_key:
return
if not (message := FeedbackMessage.objects.filter(
pk=feedback_message_id
).first()):
return
admin_url = settings.WEBSITE_URL + reverse(
"admin:feedback_feedbackmessage_change",
args=(message.pk,)
)
content = message.content
body = content[:120] + (
"..." if len(content) > 120 else ""
)
payload = json.dumps({
"title": f"{message.submitter_name}:",
"body": body,
"url": admin_url,
})
dead_endpoints = []
for sub in PushSubscription.objects.all():
try:
webpush(
subscription_info={
"endpoint": sub.endpoint,
"keys": {"p256dh": sub.p256dh, "auth": sub.auth},
},
data=payload,
vapid_private_key=private_key,
vapid_claims={"sub": f"mailto:{settings.VAPID_ADMIN_EMAIL}"},
)
except WebPushException as exc:
# 410 Gone / 404 Not Found means the subscription has expired
if exc.response is not None and exc.response.status_code in (404, 410):
dead_endpoints.append(sub.endpoint)
if dead_endpoints:
PushSubscription.objects.filter(
endpoint__in=dead_endpoints,
).delete()
The Huey task is already called from the view in feedback app via transaction.on_commit. Using on_commit ensures the task is only queued after the database row is fully committed, so the task always finds the message when it runs.
Step 12. Content Security Policy
If the project uses Django CSP, two directives need adjusting:
CSP_WORKER_SRC = ["'self'"] # allows service worker registration from this origin
CSP_CONNECT_SRC = ["'self'"] # allows the subscribe POST fetch
The pywebpush HTTP call to the external push service (FCM, Mozilla) runs server-side inside the Huey worker process and is not subject to the browser's CSP.
User experience
It' safest to keep the body of the message up to 120 characters long - the rest will likely be cut on different OSes or browsers.
You can check the current status of notification permissions by:
Notification.permission // should be "granted", not "default" or "denied"
Based on that you can write a specific message in the User Interface what to do if the permission has been denied.
If you have many different types of notifications, you would set the configuration in a Django website. And let the visitor subscribe to the notifications in the browser once. Then your Huey tasks would check the notification settings and trigger send according messages to the subscribers.
For example, for DjangoTricks website, I would allow subscribing to new tricks, blog posts, and goodies in the notification configuration, and the visitors would grant permission to Web Push notifications just once.
Privacy and security
Messages themselves are stored on the Web Push servers encrypted, but back in the OS they are shown in plain text, and can be seen by people standing behind the user or possibly read out by other apps (or viruses) which have permissions to access OS notifications.
Practical recommendations for sensitive content:
- Send a generic notification ("You have a new message") and make the user open the app to see the actual content - this is what most banking/healthcare apps do.
- If you do include content, keep it minimal - avoid full message text, Personally Identifiable Information (PII), medical info, etc.
- Make sure your VAPID keys are securely stored and rotated if compromised.
- Set a short TTL (time-to-live) on the push message so it doesn't sit on Google's servers long if the device is offline.
For anything regulated (HIPAA, GDPR, financial data), the safest approach is the generic notification pattern, since you have no control over how the OS handles notification display and history once it leaves the browser.
OS permissions
For notification receivers, the permissions can be denied for the Browser globally as well.
One can set or unset them on MacOS at Settings β Notifications β Google Chrome (or another browser analogically) or on Windows at Focus Assist / Notification settings.
Marketing perspective
Opt-in: When asking for push notifications without context, only 5-25% would grant the permission. If the permission is asked in the UI at first and the reason is given, about 60% would grant the permission.
Opt-out: On average, nearly 8-10% of subscribers opt out from web push notifications per year. Even just 1 push notification per week leads to 10% of users disabling notifications. 46% of users disable notifications if they receive more than 6 notifications.
Final words
The technique of Web Push is not trivial, but with the help of py-vapid and pywebpush, it becomes manageable. The best use cases for push notifications are those where a SaaS project or a web platform suggests to use this technique intentionally: when waiting for something to happen, such as a new comment, a reply to a message, a new task to do from another person, or a new post of a favorite author who writes irregularly.
Cover picture by cottonbro studio
05 Jun 2026 5:00pm GMT
