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
Issue 340: Django security releases 6.0.6 and 5.2.15
News
Django security releases issued: 6.0.6 and 5.2.15
Five CVEs are fixed in this latest release. As ever, perhaps the best security step you can take is to always update to the latest version of Django.
Updates to Django
Today, "Updates to Django" is presented by Hwayoung from Djangonaut Space! 🚀
Last week we had 13 pull requests merged into Django by 8 different contributors - including 4 first-time contributors! Congratulations to Vishwa, Tim Harris, Codequiver, and Joe Babbitt for having their first commits merged into Django - welcome on board!
This week's Django highlights: 🦄
- Deprecated the safe parameter of JsonResponse, as the browser vulnerability it protected against was fixed in ECMAScript 5. #36905
Releases
Python Release Python 3.15.0b2
Python 3.15.0b2, the second beta of four, is out with an explicit push for third-party maintainers to test now and file issues as early as possible. The release targets feature-complete beta with no ABI changes after beta 4, and recommends delaying production releases until 3.15.0rc1.
Python Software Foundation
PSF Strategic Plan 2026 Draft: Open for Community Feedback
PSF is publishing the full Strategic Plan 2026 draft and opening a three-week feedback window ending June 25. The board asks reviewers to focus on whether the goals and objectives are right, while implementation details will be shaped later by staff.
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
Showcasing allauth IdP: build an MCP server | allauth
Learn how to use Django and django-allauth to secure MCP endpoints with OIDC, including token validation, client registration, and host authorization flows.
Django: introducing django-integrity-policy
From Adam Johnson, a new security header and detailed article laying out the "why."
Dependency Pruning
Tips on how to treat every lockfile entry as an attack surface and maintenance burden you do not want, then start by deleting dependencies you never import.
Loopwerk: uv is fantastic, but its package management UX is a mess
uv shines for Python toolchains, but its package maintenance UX is rough: there is no straightforward uv outdated, and the upgrade workflow (uv lock --upgrade) can aggressively pull in breaking major releases.
Python 3.15: features that didn't make the headlines
Python 3.15 beta highlights worth a look: TaskGroup.cancel for graceful cancellation, ContextDecorator fixing decorator lifecycles for async and generators, a new threading iterator helpers to avoid broken state, and immutable JSON support via frozendict and an array_hook.
Please add an RSS Feed to Your Site
RSS is still the cleanest way to keep up with the people you actually want to hear from. If you host a personal site with Django, add an RSS feed quickly with a simple, up-to-date tutorial and ship it.
Using Read the Docs to benefit Django
Read the Docs can integrate with EthicalAds, letting maintainers earn a little from their documentation.
The Pursuit Of Purity (The Right Way To Do AI)
A thoughtful look at competing takes on AI ethics, from safety-first big-lab work to open, locally run, consensually sourced models.
Django Forum
django-alauth 65.18.0 released: IdP demo time
django-allauth 65.18.0 was just shipped with a bunch of Identity Provider (IdP) improvements!
Daphne v4.2.2 release
Daphne v4.2.2 is now available on PyPI. It fixes a couple of moderate/low security issues and is a recommended update for all users.
Django Fellow Reports
Natalia Bidart
My primary focus this week was polishing the upcoming security release. I spent time going deeper into areas I am less familiar with to ensure everything was in good shape for release. As release manager, this included reviewing and completing release notes, preparing backports for all three supported stable branches, and crafting the corresponding CVE metadata so records are ready ahead of disclosure (this is part of our CNA responsibilities).
Sarah Boyce
I was at PyCon Italia this week, which was fantastic, highly recommend going if you get the chance.
Jacob Walls
After a Monday holiday in the US, I spent a week focusing on contributions from the prior week's PyCon sprint.
Events
PyBay 2026
October 3rd in San Francisco this year. The Call for Proposals (CP) is open until July 8th.
Django Job Board
Founding Engineer at MyDataValue
Projects
feincms/feincms3-cookiecontrol
Cookie banner with support for embedded media.
adamghill/dj-lite-tenant
Multi-tenant SQLite databases for Django.
05 Jun 2026 2:00pm GMT
Planet Python
Will Kahn-Greene: Bleach 6.4.0 releases -- final release
What is it?
Bleach is a Python library for sanitizing and linkifying text from untrusted sources for safe usage in HTML.
Bleach v6.4.0 released!
Bleach 6.4.0 includes two security fixes, a fix to tinycss2 dependency requirements, and some other things.
See the changes here:
https://bleach.readthedocs.io/en/latest/changes.html#version-6-4-0-june-5th-2026
Bleach v6.4.0 is the final release
I haven't used Bleach on a project in years, but I still had some time to maintain it. That changed about a year ago when I got re-orged into a new role and I haven't had time to do any Bleach work since then.
To recap, Bleach sits on top of html5lib which hasn't been actively maintained in years. It is dangerous to maintain Bleach in that context.
We vendored html5lib so we could make adjustments to the library to keep Bleach going. This is not a sustainable approach, but it was ok for the short term.
Over the years, we've talked about other options:
-
find another library to switch to
-
take over html5lib development
-
fork html5lib and vendor and maintain our fork
-
write a new HTML parser
-
etc
None of those are feasible for me.
Bleach has been a solo-maintained project for a while now. The world is crazy and it's much harder to build a team of trusted maintainers now than it was (or at least, it sure feels that way). I don't see any possibility of increasing the maintenance team or passing it to someone else responsibly.
Switching contexts from my regular work to Bleach is really hard. Bleach is complicated, the problem domain is complicated, and there's a lot of nuanced context. I can't just switch gears, spend 15 minutes on Bleach to do something, and then switch back to the rest of my day. I periodically get nag messages about this which are entirely valid, but there's nothing I can do about it. It doesn't feel great.
Then in 2025, Emil, a long-time Bleach contributor, built justhtml which gives us an easy migration path off of Bleach. He even took the time to write a migration guide.
Thoughts and statistics
In 2019, when I stepped down the first time, I wrote a post on stepping down.
In 2023, when I deprecated the project, I wrote a post on Bleach 6.0.0 and deprecation.
-
From the first commit on 2010-02-18 to today's final commit on 2026-06-05, the Bleach project lasted 16 years, 3 months - 5,951 days, or about 16.29 years.
-
There were 64 releases.
-
There were roughly 960 commits.
-
From 80 roughly contributors
-
Top 3:
-
Will Kahn-Greene: 462
-
James Socol: 182
-
Greg Guthe: 133
-
-
-
Roughly 5,040 lines of Python code excluding the vendored html5lib.
-
I was maintainer from October 2015 to now--that's a little under 11 years.
It feels weird to end a project that's outlived many of the Mozilla sites and Python web frameworks it was designed to protect.
What happens now?
This is the end of the project.
Bleach. Last release.
If you're still using Bleach, I think you have three options:
-
End your project. Maybe you don't need to be maintaining your thing anymore? Use Bleach as your reason to exit and do something different with your time on Earth.
-
Switch to the sanitizer API. Rework your project to use the sanitizer API.
-
Swap Bleach out for justhtml. Emil provided a migration guide for switching from Bleach to justhtml.
Good luck with whatever option you choose!
Thanks!
Many thanks to James who created Bleach and gave it a set of first principles that guided our choices for 16 years.
Many thanks to Greg who I worked with on Bleach for a long while and maintained Bleach for several years. Working with Greg was always easy and his reviews were thoughtful and spot-on.
Many thanks to Emil who was a contributor to Bleach for a long while and created justhtml providing Bleach users a migration path.
Many thanks to Jonathan who, over the years, provided a lot of insight into how best to solve some of Bleach's more squirrely problems.
Many thanks to Sam who was an indispensible resource on HTML parsing and sanitizing text in the context of HTML.
Many thanks to all the users and contributors of Bleach!
Where to go for more
For more specifics on this release, see here: https://bleach.readthedocs.io/en/latest/changes.html#version-6-4-0-june-5th-2026
Documentation and quickstart here: https://bleach.readthedocs.io/en/latest/
Source code and issue tracker here: https://github.com/mozilla/bleach/
05 Jun 2026 1:00pm GMT
Real Python: The Real Python Podcast – Episode #298: Reducing the Size of Python Docker Containers
How can you easily reduce the size of a Python Docker container? What are the exceptions you should catch in your code? Christopher Trudeau is back on the show this week with another batch of PyCoder's Weekly articles and projects.
[ Improve Your Python With 🐍 Python Tricks 💌 - Get a short & sweet Python Trick delivered to your inbox every couple of days. >> Click here to learn more and see examples ]
05 Jun 2026 12:00pm GMT
EuroPython Society: EuroPython Society at PyCon US 2026
This year we were back at PyCon US, and this time in sunny Long Beach, California.
We had a booth again, which has quickly become one of our favourite parts of the trip. It&aposs such a great chance to meet folks from other Python communities, catch up with old friends, and put faces to names we&aposve only seen online. People stopped by to chat about EuroPython, pick up stickers, ask about our grants programme, and share what their own local communities are up to. We loved every minute of it.


We also filmed some shorts at the booth, which will be up on our YouTube channel soon! Keep an eye out, there are some lovely conversations in there.
Since EuroPython is celebrating its 25th anniversary this year, we took the chance to talk to community members who have been to many, many EuroPythons over the years. Hearing their stories, the editions they remember most, the friendships that started at one of our conferences, was genuinely moving. It&aposs a good reminder of how much history this community carries with it, and how much of it has been built by people simply showing up year after year.
PyCon US was also where some wonderful people from our community received well-deserved recognition. A huge congratulations to Maria Jose Montreas-Colina, who received an Outstanding PyLady Award for her work with PyLadies and the wider community. Maria is part of our team and helps look after PyLadies and community matters at EuroPython. Congratulations also to Rodrigo Girão Serrão for receiving the Community Service Award for his contributions to the community. Rodrigo works on our programme and sprints.
Thank you both for everything you do. 💛


Long Beach itself was a lovely city. Palm trees, warm weather, the ocean nearby. A very different vibe from the usual conference cities, and a really lovely backdrop for a week of Python.
A big thank you to the PyCon US organisers for having us, and for making space for the wider Python world to come together. And a thank you to everyone who stopped by the booth to say hello, it was a pleasure meeting you.
See you next year, and we hope to see many of you in Kraków for EuroPython 2026!


05 Jun 2026 9:28am GMT
03 Jun 2026
Django community aggregator: Community blog posts
Anything new?
Anything new?
A lot of time has passed since I officially announced that I want to step down from maintaining django-mptt. I started contributing around 2009, tagged the 0.3 release in April 2010, and have been the sole active maintainer since somewhere around 2019. The post about django-tree-queries has more background, but that's not today's topic.
Stepping away isn't easy
For me, abandoning a project is a bit like stepping out of a relationship: negative emotions end up being a somewhat necessary driver, because the absence of positive events alone rarely provides enough force on its own. I get a lot of satisfaction from a job well done, and walking away means letting that go.
Even with time set aside for open source in my work day, I still have to choose where that time goes. django-mptt stopped being where it needed to go.
The sense of entitlement
When a project is obviously unmaintained, asking for free labor is walking a tightrope. It takes real care not to rekindle exactly the frustrations that led maintainers away in the first place.
It takes energy not to clap back when someone is being rude or insensitive in the issue tracker. Asking "Anything new?" on a ticket where the next steps were outlined clearly and obviously nothing happened in the meantime is just one variant of this.
Quietly quitting isn't what I want to do - and as a user of django-mptt myself, I can't really do that either. Taking the high road is the professional choice. But it costs something.
I keep coming back to Mona Eltahawy on refusing to be civil. She's speaking about something quite different, and I'm aware I write this as a white man. The situations aren't the same at all. But she articulated something I haven't managed to put into words as well myself and I like the idea of speaking up and taking the fight to those who awaken these feelings instead of taking the high road.
Doing it with AI
No post these days is complete without the obligatory AI mention, but there's some relevancy to it.
I fixed and closed almost all open django-mptt issues in a two-hour Claude session. I've previously written about using LLMs for open source maintenance, and the productivity gain is real whatever the detractors say. And the quality isn't suddenly getting worse. Code wasn't perfect before either. The test suite allows a certain degree of trust in the result and according to my rules for releasing Open Source software we don't have to require more than that.
It doesn't change the underlying dynamic, though. rsync and outrage illustrates the trap neatly: Tridgell got flooded with AI-generated security reports, used AI to handle them, and then got criticized for using AI. The tools that created the workload aren't allowed to address it. The expectation is that the work has to involve sweat and tears and uncountable unpaid hours.
The common goal should be more and better open source software. What we get as Open Source maintainers is shit from both sides: One side took our free work and trained models on it without asking, the other side complains about the supposedly unethical use of AI while acting in unethical ways themselves.
There's something Kantian about how open source contribution gets framed. Kant's argument was that the only truly moral acts are those driven by duty and good will - not by desire, inclination, or any expectation of compensation. By that logic, I'm only acting morally if I keep going despite the burnout and the entitlement. If I stop, I'm not.
It's bleak. The problems with AI are real. The people controlling the large models are assholes. But I have to work in the world as it is while also trying to change it for the better.
03 Jun 2026 5:00pm GMT
22 May 2026
Planet Twisted
Glyph Lefkowitz: Opaque Types in Python
Let's say you're writing a Python library.
In this library, you have some collection of state that represents "options" or "configuration" for a bunch of operations. Such a set of options is a bundle of potentially ever-increasing complexity. Thus, you will want it to have an extremely minimal compatibility surface, with a very carefully chosen public interface, that is either small, or perhaps nothing at all. Such an object conveys state and might have some private behavior, but all you want consumers to be able to do is build it in very constrained, specific ways, and then pass it along as a parameter to your own APIs.
By way of example, imagine that you're wrapping a library that handles shipping physical packages.
There are a zillion ways to do it ship a package. There are different carriers who can ship it for you. There's air freight, and ground freight, and sea freight. There's overnight shipping. There's the option to require a signature. There's package tracking and certified mail. Suffice it to say, lots of stuff.
If you are starting out to implement such a library, you might need an object called something like ShippingOptions that encapsulates some of this. At the core of your library you might have a function like this:
1 2 3 4 5 |
|
If you are starting out implementing such a library, you know that you're going to get the initial implementation of ShippingOptions wrong; or, at the very least, if not "wrong", then "incomplete". You should not want to commit to an expansive public API with a ton of different attributes until you really understand the problem domain pretty well.
Yet, ShippingOptions is absolutely vital to the rest of your library. You'll need to construct it and pass it to various methods like estimateShippingCost and shipPackage. So you're not going to want a ton of complexity and churn as you evolve it to be more complex.
Worse yet, this object has to hold a ton of state. It's got attributes, maybe even quite complex internal attributes that relate to different shipping services.
Right now, today, you need to add something so you can have "no rush", "standard" and "expedited" options. You can't just put off implementing that indefinitely until you can come up with the perfect shape. What to do?
The tool you want here is the opaque data type design pattern. C is lousy with such things (FILE, pthread_*_t, fd_set, etc). A typedef in a header file can easily achieve this.
But in Python, if you expose a dataclass - or any class, really - even if you keep all your fields private, the constructor is still, inherently, public. You can make it raise an exception or something, but your type checker still won't help your users; it'll still look like it's a normal class.
Luckily, Python typing provides a tool for this: typing.NewType.
Let's review our requirements:
- We need a type that our client code can use in its type annotations; it needs to be public.
- They need to be able to consruct it somehow, even if they shouldn't be able to see its attributes or its internal constructor arguments.
- To express high-level things (like "ship fast") that should stay supported as we add more nuanced and complex configurations in the future (like "ship with the fastest possible option provided by the lowest-cost carrier that supports signature verification").
In order to solve these problems respectively, we will use:
- a public
NewType, which gives us our public name... - which wraps a private class with entirely private attributes, to give us an actual data structure, while not exposing the constructor,
- a set of public constructor functions, which returns our
NewType.
When we put that all together, it looks like this:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
|
As a snapshot in time, this is not all that interesting; we could have just exposed _RealShipOpts as a public class and saved ourselves some time. The fact that this exposes a constructor that takes a string is not a big deal for the present moment. For an initial quick and dirty implementation, we can just do checks like if options._speed == "fast" in our shipping and estimation code.
However, the main thing we are doing here is preserving our flexibility to evolve the related APIs into the future, so let's see how we might do that. For example, let's allow the shipping options to contain a concrete and specific carrier and freight method:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 |
|
As a NewType, our public ShippingOptions type doesn't have a constructor. Since _RealShipOpts is private, and all its attributes are private, we can completely remove the old versions.
Anything within our shipping library can still access the private variables on ShippingOptions; as a NewType, it's the same type as its base at runtime, so it presents minimal1 overhead.
Clients outside our shipping library can still call all of our public constructors: shipFast, shipNormal, and shipSlow all still work with the same (as far as calling code knows) signature and behavior.
If you need to build and convey some state within your public API, while avoiding breakages associated with compatibility churn, hopefully this technique can help you do that!
Acknowledgments
Thanks for reading, and thank you to my patrons who are supporting my writing on this blog. If you like what you've read here and you'd like to read more of it, or you'd like to support my various open-source endeavors, you can support my work as a sponsor.
-
The overhead is minimal, but it is not completely zero. The suggested idiom for converting to a
NewTypeis to call it like a function, as I've done in these examples, but if you are wanting to use this pattern inside of a hot loop, you can use# type: ignore[return-value]comments to avoid that small cost. ↩
22 May 2026 12:33am GMT
04 Apr 2026
Planet Twisted
Donovan Preston: Using osascript with terminal agents on macOS
Here is a useful trick that is unreasonably effective for simple computer use goals using modern terminal agents. On macOS, there has been a terminal osascript command since the original release of Mac OS X. All you have to do is suggest your agent use it and it can perform any application control action available in any AppleScript dictionary for any Mac app. No MCP set up or tools required at all. Agents are much more adapt at using rod terminal commands, especially ones that haven't changed in 30 years. Having a computer control interface that hasn't changed in 30 years and has extensive examples in the Internet corpus makes modern models understand how to use these tools basically Effortlessly. macOS locks down these permissions pretty heavily nowadays though, so you will have to grant the application control permission to terminal. But once you have done that, the range of possibilities for commanding applications using natural language is quite extensive. Also, for both Safari and chrome on Mac, you are going to want to turn on JavaScript over AppleScript permission. This basically allows claude or another agent to debug your web applications live for you as you are using them.In chrome, go to the view menu, developer submenu, and choose "Allow JavaScript from Apple events". In Safari, it's under the safari menu, settings, developer, "Allow JavaScript from Apple events". Then you can do something like "Hey Claude, would you Please use osascript to navigate the front chrome tab to hacker news". Once you suggest using OSA script in a session it will figure out pretty quickly what it can do with it. Of course you can ask it to do casual things like open your mail app or whatever. Then you can figure out what other things will work like please click around my web app or check the JavaScript Console for errors. Another very important tips for using modern agents is to try to practice using speech to text. I think speaking might be something like five times faster than typing. It takes a lot of time to get used to, especially after a lifetime of programming by typing, but it's a very interesting and a different experience and once you have a lot of practice It starts to to feel effortless.
04 Apr 2026 1:31pm GMT
16 Mar 2026
Planet Twisted
Donovan Preston: "Start Drag" and "Drop" to select text with macOS Voice Control
I have been using macOS voice control for about three years. First it was a way to reduce pain from excessive computer use. It has been a real struggle. Decades of computer use habits with typing and the mouse are hard to overcome! Text selection manipulation commands work quite well on macOS native apps like apps written in swift or safari with an accessibly tagged webpage. However, many webpages and electron apps (Visual Studio Code) have serious problems manipulating the selection, not working at all when using "select foo" where foo is a word in the text box to select, or off by one errors when manipulating the cursor position or extending the selection. I only recently expanded my repertoire with the "start drag" and "drop" commands, previously having used "Click and hold mouse", "move cursor to x", and "release mouse". Well, now I have discovered that using "start drag x" and "drop x" makes a fantastic text selection method! This is really going to improve my speed. In the long run, I believe computer voice control in general is going to end up being faster than WIMP, but for now the awkwardly rigid command phrasing and the amount of times it misses commands or misunderstands commands still really holds it back. I've been learning the macOS Voice Control specific command set for years now and I still reach for the keyboard and mouse way too often.
16 Mar 2026 11:04am GMT