25 Aug 2026

feedDjango community aggregator: Community blog posts

Death by a thousand reasonable decisions

When I read consistent, cohesive code, I have a feel for its author. I may disagree with the ideas, but I can feel where the author is driving it. Other times the feel is split-brain - the code's "energy" is fractured, the work of unaligned hands each solving its own small problem, none with an idea where the whole is going. Immediately I feel it should be rewritten or refactored holistically.

Here is the paradox. Most of us are dedicated professionals with years of experience, and bad code is everywhere. Programmers I respect produce it; my own code is far from perfect. Why?

Death by a thousand reasonable decisions

25 Aug 2026 10:00am GMT

24 Aug 2026

feedDjango community aggregator: Community blog posts

Django Developers Survey 2026

πŸ”— Links

πŸŽ₯ YouTube

24 Aug 2026 5:32pm GMT

23 Aug 2026

feedDjango community aggregator: Community blog posts

When Python is Too Slow

Python is a perfect language for Agile development, where requirements might change on the go. Especially if you are in a startup business, you will need to experiment and change things fast. However, Python is an interpreted language, and in certain situations you might need faster performance than what an interpreted language can provide. A common practice in these cases is using python-to-binary bindings, where the binary code is built with Rust, C++, or Go. In this article, I will explore bindings to Rust-based code.

How do the bindings work

The idea behind bindings is that you create a module with functions of a specific domain in a language that compiles to binary, and build it as a C-compatible dynamic library (.so on Linux, .dylib on macOS, .dll on Windows). Then a Python wrapper is built as a Python package and installed together with the dynamic library, allowing you to import and use functions that pass control to the corresponding functions in the dynamic library. On some occasions, classes can be used instead of functions. If any parameters are complex, they must be serialized in the wrapper and passed to the dynamic library as a JSON string or as a set of individual primitive parameters.

An experiment with benchmarks

To try this Python-Rust communication, I vibe coded an experiment that reads a large CSV file and builds a new one with duplicates stripped out based on specified column indexes. In my test case, it was a 3 MB CSV file with data about European NGOs for the donation platform I am building, where I wanted to remove the NGOs that don't have website URLs listed. As benchmarked, the file was processed 4.3x faster with the Rust binding than directly with Python.

Here is the repo to get a first glimpse into the code and structure.

What is there to know about Rust

A few things about Rust: Rust packages are built with Cargo, which is the equivalent of pip, virtualenv, and setuptools combined. A single package is called a crate, and it can be published to crates.io, the equivalent of PyPI. To create a Python-to-Rust binding, the standard approach is to use Rust's PyO3 library together with maturin, a build tool installable as a PyPI package.

Rust syntax is not the most developer-friendly compared to Python or Go, but with today's AI agents, most Python-native code can be ported to it fairly easily. The good thing about Rust and Go compared to C++ is that you don't have to manage memory at a low level or work with pointers directly.

The package structure

The common file structure can be:

thepackage_rs/           # the package - self-contained and installable
β”œβ”€β”€ Cargo.toml           # crate manifest (pyo3, etc.)
β”œβ”€β”€ pyproject.toml       # maturin build backend config
β”œβ”€β”€ src/lib.rs           # Rust implementation
└── python/
    └── thepackage_rs/
        └── __init__.py  # Python wrapper

Once the code is ready, you build it with:

(.venv) maturin develop --release

This builds the Python package and installs it into the current virtual environment. You can also get the wheel at thepackage_rs/target/*.whl, built for your specific operating system.

Rust in Django

When developing Django websites, the biggest bottlenecks are usually not in the language itself, but in the connections to databases, file systems or object storage, and APIs. Still, in cases where you need to process large amounts of data or do heavier calculations, using a binary instead of Python makes sense. The orjson library, and DRF renderers built on top of it such as django-orjson or drf-orjson-renderer, are good examples of this - Rust boosts the speed of building or parsing JSON 2-10x compared to a Python-native implementation. The best places to use Rust replacements are background tasks, management commands, template parsing (see the experimental django-rusty-templates), and occasionally views or middleware.

Final words

So Python itself is good enough, especially when it comes to code readability and speed of development. However, when needed, certain parts can be improved 2 to 10x just by rewriting them as Python-Rust bindings. Just keep in mind that when it comes to views with data from the database or Elasticsearch, post-processing - such as reordering in Python or Rust - is an antipattern; do that directly in the queries instead. Finally, keep in mind, that building with Rust will need extra dependencies and maintainance in your workflows.


Cover photo by Alex Tepetidis

23 Aug 2026 5:00pm GMT

21 Aug 2026

feedDjango community aggregator: Community blog posts

Fuzzy String Matching in Django and PostgreSQL

Gerald Carlton and I will be presenting on fuzzy name search at DjangoCon US 2026 on Monday, August 24, and this is the companion blog post. Searching for a person by name is harder than it looks: names might be typed differently by different operators or change over a person's lifetime; for example, Smith could be entered as Smyth, Smythe, or Smidt, and Weiss as Weiß. Although names are particularly susceptible to misspelling, these strategies apply to all fuzzy string matching.

21 Aug 2026 6:00pm GMT

Issue 351: DjangoCon US last call, Djangonaut Space applications open

We'll see you in Chicago! Jeff and Will are heading to DjangoCon US next week, so if you're going too, come say hi.


News

Almost Time: What to Know Before You Head to Chicago

DjangoCon US 2026 kicks off Monday, August 24 in Chicago, with registration and breakfast starting at 7:30 AM on the 14th floor. Watch for Friday's attendee email with your Slack invite and lightning-talk sign-up, and if you're sticking around for sprints on August 27-28, grab a free sprints ticket now.

New One-Day and Online Tickets

Can't make the whole week? DjangoCon US 2026 added one-day in-person tickets that also include online access to every other session, plus online-only tickets for remote attendees.

DSF Membership Open Space at DjangoCon US

Bring your questions about the Foundation to DSF board directors on Wednesday, August 26, 1:00 to 1:45 PM in the Wolf Point Ballroom.


Djangonaut Space News

Session 7 is preparing to take flight πŸ’«

Session 7 runs October 12 through December 6, with teams of three or four Djangonauts guided by a Navigator and a Captain, working on Django Core, django CMS, Django Debug Toolbar, the Django Girls+ website, BeeWare, and Render Engine. Plan on about five hours a week, and note that past sessions have accepted roughly 10% of applicants, so fill out every section. Applications close September 6, 2026, Anywhere on Earth.


Updates to Django

Today, "Updates to Django" is presented by Raffaella from Djangonaut Space! πŸš€

Last week we had 13 pull requests merged into Django by 6 different contributors - including 2 first-time contributors! Congratulations to Karan Suthar and Jens Spanier for having their first commits merged into Django - welcome on board!

News in Django 6.1:


Django Fellow Reports

Django Fellow Report - Jacob

Jacob Tyler Walls spent the week sweeping up after the 6.1 release, triaging admin changelist search crashes on __exact lookups, a Model.from_db() override crash, and unnecessary DDL when only a Python-level on_delete changes. He also accepted tickets for calver support in django.utils.version and for updating the release process docs under DEP 20, while GIS, caching, and the GSoC multi-column subquery work continued.

Django Fellow Report - Sarah

Sarah Boyce worked through Django 6.1 release blockers: an admin crash when ModelAdmin.get_action() is overridden with its pre-6.1 signature, values() querysets crashing on models with Meta.ordering, DecimalField without precision on SQLite, and second-degree relations in ModelAdmin.list_display looking up values on the wrong model. She also documented the YYYY[.N] versioning scheme and updated the download page for the newly approved annual release cycle.


Sponsored

Simple APM dashboards for Python. Set up in just 5 minutes

Drowning in data? Honeybadger gives you Just Enough APMβ„’ without enterprise bloat or cost.

Our dev-friendly APM dashboards expose metrics and trends across your apps and infrastructure, so you can find and fix Python issues before users notice!


Articles

Nifty Django Feature: Counting on Multiple Columns

Count only counts one column at a time, so counting unique pet-and-vet pairs returns either every appointment or every distinct vet, never the answer you wanted. The fix is a small Subquery subclass whose template wraps values("pet", "vet").distinct() in a SELECT COUNT(*), which is a nice demonstration of how far Django's expression system bends.

django-upgrade 1.32.0 out now, with 44 AI-assisted bug fixes

Adam Johnson turned Claude loose on django-upgrade with the prompt "Find and fix bugs" and shipped 44 fixes across two rounds. The catches include url() to path() conversions that mangled regexes with literal angle brackets or unescaped dots, a five-year-old fixer that rewrote calls to html.escape() when it meant html.unescape(), and a multi_db = False mapping that produced an empty database list and blocked every query in a test.

Nearly 20 years of choosing Django

Lincoln Loop has attended or sponsored most DjangoCons since the first one in California in 2008, where founder Peter Baumgartner gave an early lightning talk. The team credits the conference with teaching them how to work a booth, order swag people actually wear, talk to strangers, and, for several of them, get over a fear of public speaking.

Docker Compose reads .env files by default

Compose reads the .env file in the directory you run it from and honors any COMPOSE_* variables it finds there, which is easy to miss if you think of .env as something that only reaches your containers. Frank Wiles uses it to set COMPOSE_FILE=compose.yml:overlay-compose.yml per directory, so a worktree can remap ports and add labels without touching the shared compose file.


Events

Start DjangoCon US at the Welcome Reception

Kick off the conference Sunday, August 23, 7-9 PM in the 15th Floor Lobby, with casual conversation and drinks courtesy of REVSYS, Two Rock Software, and Caktus Group.

Sign up for Travel Safety Checks & Chicago Travel Safety Updates

Traveling to Chicago from out of town or abroad? Sign up for the optional Travel Safety Check so someone can check in with you by WhatsApp or Signal once you've arrived.

Introducing Open Spaces! (And Why You Should Host One)

Pitch a topic on a sticky note and host your own participant-led discussion during Monday and Tuesday's Open Spaces sessions in the Wolf Point Ballroom.


DjangoCon US

TIME RUNNING OUT! 10% off

Join us in Chicago or ONLINE! August 24-26 for the main conference, followed by two days of sprints. Online and one day tickets also available.


Videos

Interview with Django Expert Paolo Melchiorre on AI and Open Source

DSF Board Member Paolo Melchiorre talks about Django, AI-assisted development, open-source maintainership, and how the Python community is adapting to AI.

Why AI Coding Agents Fail on Real Codebases - Sheena O'Connell on Spec-Driven Development

PSF director and Django developer Sheena O'Connell sat down at PyCon US to discuss spec-driven development, AI agents, and why so many teams struggle to make agentic coding work in practice.


Django Job Board

Full Stack Software Engineer (Hybrid) at Provision

Toronto-based construction-AI startup building full-stack product surfaces and AI-forward document-processing systems.

Executive Director at Django Software Foundation

Remote US role running the DSF's fundraising, operations, and public representation as its first Executive Director.

Security Developer at Python Software Foundation

Global remote role triaging CPython/PyPI vulnerabilities and remediating supply-chain attacks alongside the Python Security Response Team.

Senior Full Stack Engineer at Hive Collective

US-only remote, backend-leaning full-stack role at a profitable LegalTech SaaS with an AI-assisted workflow.

Senior Backend Engineer at MyOme

Remote US backend role building portal integrations for patients and providers ordering genetic testing.

Python + TypeScript Engineers at Fusionbox

Remote US consultancy role building Django/React systems for financial workflows and multi-tenant architectures.


Projects

alzeph/django-forge-log

A lightweight, automatic audit trail - Who, What, When, Where, and the before/after Diff - for Django views (FBV, CBV, DRF ViewSets) and the Admin, stored in a single central JSON table.

tim-schilling/django-salmon

Does the monkey-patching of Django's internals once, then exposes a standardized set of signals so APM and observability tools do not each have to reinvent it. Receivers get args, result, timing, and a lazy stacktrace, and the decorator stack per facet is configurable.

21 Aug 2026 3:00pm GMT

18 Aug 2026

feedDjango community aggregator: Community blog posts

Exotic `goto`: generators and exceptions

The last post argued that break, continue, and labeled jumps are structured goto - disciplined jumps that keep code linear. Two ordinary language features are jumps in disguise: yield, which suspends a function and later resumes it in the middle, and throw, which leaps across stack frames to a waiting handler.

Exotic goto: generators and exceptions

18 Aug 2026 10:00am GMT

Django: django-upgrade 1.32.0 out now, with 44 AI-assisted bug fixes

django-upgrade is my tool for automatically upgrading your project code for new Django versions. It rewrites your Python files to fix deprecations and adopt some new features, taking a chunk of the monotony out of upgrading between Django versions.

Yesterday, I released version 1.32.0, which fixes 44 bugs. Some are big, some are small, and all of them were found by Claude Fable, with this simple prompt:

Find and fix bugs

Yup, that's it. Across two rounds of self-directed bug discovery, Claude found and fixed these bugs, matching the coding style and changelog entries. It needed one more prompt to split the fixes into individual commits.

I am pretty astounded at how well this little project worked. Claude worked "in the cloud", while I was doing other stuff, so the bottlenecks to progress were my review capacity and CI runs on GitHub Actions for each commit.

My big takeaway is that given such LLM power, the bar for software quality should be raised.

Let's review some of the bugs that it fixed, within the individual code fixers in django-upgrade.

Bad url() to path() conversions

The django_urls fixer converts old url() calls, with their regular expression patterns, into path() calls with the newer route syntax, where possible.

Claude found two cases where the "where possible" condition was too optimistic.

First, literal angle brackets. In a regular expression, < and > are literal characters, but in path() route syntax they declare parameters. The fixer copied them through unchanged:

-    url(r"^go/<page>/$", views.redirect_angle),
+    path("go/<page>/", views.redirect_angle),

The old pattern matched only the exact URL /go/<page>/, angle brackets included. The new route matches /go/anything/ and passes page as a keyword argument to the view, which likely isn't expecting it. And if the bracketed text isn't a valid Python identifier, like <not-a-name>, Django instead raises ImproperlyConfigured at startup. PR #715 made the fixer skip such patterns.

Second, unescaped dots. In a regular expression, a bare . matches any character, whilst \. matches only a literal dot. The fixer treated both the same, converting:

-    url(r"^report.pdf$", views.report),
+    path("report.pdf", views.report),

The old pattern also matched URLs like /reportxpdf, so the conversion silently narrowed which URLs the pattern matches. Most such patterns contain a "latent bug" where the author meant \., but it's not django-upgrade's place to change behaviour. PR #710 made the fixer leave patterns with unescaped dots alone.

These two bug fixes have hopefully closed a loop for me on a client project. Last year, I tried applying django-upgrade to a large client project, and it failed some tests. In my investigation, I cut down the fixers being applied to a short list, including django_urls, and still some tests failed. I reached the suspicion that some URLs were being converted incorrectly, but I ran out of time to properly investigate. Now a bot has found and fixed these bugs without me even trying, I'll be trying that upgrade again!

Escaping backwards

Django 3.0 deprecated django.utils.text.unescape_entities() in favour of Python's html.unescape().

Back in version 1.2.0 (2021), I added the unescape_entities fixer to django-upgrade to rewrite calls to the new function. But I accidentally made it rewrite calls to the inverse function, html.escape() instead of html.unescape():

-from django.utils.text import unescape_entities
+import html

-text = unescape_entities(raw)
+text = html.escape(raw)

Woops! You can see how the results would vary:

>>> html.unescape("Tom &amp; Jerry")
'Tom & Jerry'
>>> html.escape("Tom &amp; Jerry")
'Tom &amp;amp; Jerry'

😬

PR #684 was the bug fix to correctly rewrite code to use html.unescape().

This fixer survived nearly five years of use, perhaps through a combination of few projects activating it, no one noticing when it broke their code, and users potentially working around the issue by disabling the fixer. I'm glad Claude could spot the obvious error.

TestCase.multi_db = False fixer blocking all database queries

Django 2.2 replaced the test case attributes allow_database_queries and multi_db with databases.

django-upgrade rewrites those old attributes to the new one, but it mapped multi_db = False to an empty list:

 class OrderTests(TestCase):
-    multi_db = False
+    databases = []

That looks sensible at first glance, but it's wrong. Under Django's deprecation shim, multi_db = False still allowed queries against the default database. The rewritten databases = [] blocks queries against all databases, breaking previously-working tests with DatabaseOperationForbidden errors.

PR #727 corrected the mapping:

 class OrderTests(TestCase):
-    multi_db = False
+    databases = ["default"]

This is another bug I introduced in version 1.2.0. I guess no one hit this code path, since it would trigger an obvious test case breakage. But with open source, it is hard to know how many folks will actually make a bug report.

Bar height++

These bug fixes are a subset of the 44 in the release-see the changelog for the full list. There are definitely some more bugs lurking, but for now I'm out of time and energy for django-upgrade. I even left some harder-to-review bug fixes in draft PRs for my next pass at the project.

But yeah, since bugs like these are now fairly cheap to find, I hope that the software quality bar goes up. Applying fixes still requires some vigilance in review and checks from deterministic tools like linters, but good projects generally already apply such tools. And while it can be hard to trust LLMs to build features, where they often large piles of code, this genre of small, targeted bug fixes are an easy win.

Try my prompt on your own project. Here it is again:

Find and fix bugs

Fin

May robot eyeballs make all your bugs shallow,

-Adam

18 Aug 2026 4:00am GMT

14 Aug 2026

feedDjango community aggregator: Community blog posts

Issue 350: Django moves to an annual release cycle

News

Django moves to an annual release cycle

Starting in January 2028, Django switches from its eight-month cycle to one feature release per year (matching Python's annual October releases), dropping the LTS distinction in favor of every release getting one year of mainstream support followed by two years of security and data-loss fixes; nothing changes before then.

DSF Office Hours

The DSF Board holds open office hours every Wednesday at 6:00 PM UTC, no agenda or invitation needed. Right now the main topics are the Executive Director search and the 2026 fundraising goal.

Django Girls - August Newsletter

Tie-in with Djangonaut Space for a team to work on the Django Girls infrastructure during the upcoming session. Plus a call to join the Social Media Team.

PyPI freezes the HTML index API

PyPI is adopting PEP 833 to stop extending the HTML simple-index format with new metadata, pushing future additions to the JSON API instead; nothing breaks and no action is needed unless you're a mirror or bulk consumer, in which case consider switching to JSON.


Releases

Django REST Framework 3.18.0

Django REST Framework 3.18.0 drops support for Django 4.2, 5.0, and 5.1, adds Django 6.1 support, and changes list-serializer (many=True) errors to a dict format. It also adds a @throttle_scope decorator for function-based views, unaccent support in SearchFilter, and nulls_distinct support for UniqueTogetherValidator.

Python 3.12.14, 3.11.16, and 3.10.21 are now available!

Security-only releases for the three versions, fixing roughly 30 vulnerabilities including path-traversal issues in tar/ZIP extraction, billion-laughs protection in XML parsing, and (for 3.11 and 3.10) an SSL certificate parsing fix; upgrading is highly recommended.

Wagtail 8.0rc1

The first 8.0 release candidate adds custom base page models, a global permission-policy registry, and rich-text-to-HTML serialization in the v2 API. It stops downconverting AVIF/WebP images to PNG by default and adds provisional Django 6.1 support, pending django-ninja compatibility.


Django Software Foundation

Django Steering Council Meetings - 2026

At its August 3 meeting, the Steering Council voted on DEP 19 membership matters, discussed DEP 20's annual release cycle, and talked through what "Django Core Developer" should mean. Fellows also flagged DEP 19 implementation gaps, a naming question for the ORM's RAISE fetch mode, and an upcoming table-expressions proposal.


DjangoCon US

Save 10% on DjangoCon US 2026 registration

DjangoCon US 2026 runs August 24 to 28 in Chicago and ONLINE, now under two weeks away. Register through our link to take 10% off. A new One Day rate is now available if you can't make the whole week. Time is running out to get your ticket. Get yours before they are gone! Online tickets available.


Python Software Foundation

Announcing the Packaging Council Election Candidates for 2026!

The inaugural Python Packaging Council election has 17 nominees for 5 seats (the top 2 get two-year terms, the next 3 get one-year terms); PSF voting members must affirm by August 25 at 2:00 pm UTC, with voting running September 1-15.

Announcing the PSF Board Candidates for 2026!

18 candidates are running for 4 open PSF Board seats, with the same August 25 affirmation deadline and September 1-15 voting window; members who voted last election are carried over automatically unless their email changed.


Wagtail CMS News

Free talk ideas for Wagtail Space 2026

Wagtail Space 2026 runs November 18-20, and the CFP is open at pretalx.com/wagtail-space-2026/cfp; organizers are capping AI-focused talks at roughly half the program, so non-AI proposals stand a better chance.


Updates to Django

Today, "Updates to Django" is presented by Raffaella from Djangonaut Space! πŸš€

Last week we had 14 pull requests merged into Django by 8 different contributors - including 2 first-time contributors! Congratulations to Jakob Friedrich and OsmnvAslan for having their first commits merged into Django - welcome on board!

The fetch mode that raises a FieldFetchBlocked exception has been renamed to FETCH_RAISE.

Thanks to the contributor's effort, the admin save_as behavior for view-only inlines has been fixed, ensuring they are correctly rendered after validation errors.

The documentation for converting a ManyToManyField to use a through model has also been updated for converting correcting incorrect on_delete options and adding some additional caveats.


Django Fellow Reports

Django Fellow Report - Jacob

Django 6.1 shipped this week. Jacob also cross-pollinated GSoC's multi-column subquery work with a search-performance improvement he tried out on djangoproject.com.

Django Fellow Report - Natalia

Most of Natalia's week went to the security release, then assisting Jacob with the Django 6.1 final release, including translation updates that turned into a bit of a spiral.

Django Fellow Report - Sarah

Sarah's main focus was the GSoC project migrating Django's integration tests from Selenium to Playwright, which she hopes to land soon.


Articles

Nifty Feature: Admin Form Injection

Reuse Django admin's built-in popup machinery (showRelatedObjectLookupPopup() and dismissRelatedLookupPopup()) to build custom popup workflows that inject a value into an admin form field, not just the stock foreign-key picker.

Storing Django Static and Media Files on Cloudflare R2

A walkthrough of wiring django-storages and boto3 up to Cloudflare R2 (zero egress fees), using two buckets: a public one for static files and public media with querystring_auth = False, and a private one for restricted media served through signed URLs.

Introducing django-msgspec

django-msgspec swaps in msgspec, a C-based serializer several times faster than the standard library, as a drop-in replacement for JsonResponse, DRF's parsers and renderers, session serializers, and more. It handles numeric dict keys and huge integers that trip up orjson, though it encodes non-finite floats as null and rejects None/boolean dict keys.

Newlines

Why splitting text into lines is trickier than it looks, and why Python's splitlines() recognizes ten different line-break code points.

Introducing emojet, a fast emoji lookup library

Adam Johnson built emojet, a Rust-backed replacement for the emoji package's emojize()/demojize(), after finding that package's 520 KB JSON file cost 22ms just to import. emojet imports 23.7x faster, is up to 70x faster on lookups, and uses about 40% less memory.


Events

Keynote: Boldly Go, Building Worlds: Is there Room for me on the Bridge? - Dawn Wages

Dawn Wages opens DjangoCon US 2026 Monday at 9:00 AM, drawing on Star Trek and Octavia Butler's Afrofuturism to reflect on a decade in the Python community (PSF Board Chair, Django Girls, Wagtail) and what it takes to build equitable, accountable open source spaces.

What I'm looking forward to at DjangoCon US 2026

Attending for his fifth year, the author is most looking forward to the people, an open-spaces package-maintainers discussion, running Sprints as chair alongside Kudzayi Bamhare, and the speakers dinner. He'll also be handing out handmade bookmarks to fellow readers.

DjangoCon US 2026 conference prep

Three quick logistics posts from the organizers: why you should volunteer (badges, room timing, and speaker support, no experience needed), how to give a Lightning Talk (five minutes, any topic, sign-up announced onsite), and how to stay active (a Strava group, the hotel's pool and gym, and the 18-mile Lakefront Trail).


Videos

Introducing t-strings: f-strings with superpowers - Dave Peck (PyCascades 2026)

Dave Peck introduces Python 3.14's t-strings, which look like f-strings but evaluate to Template instances, helping prevent SQL/HTML injection and enabling custom formatting via libraries like tdom and t-sql. He covers when to reach for them, adoption strategies, and current editor tooling support.


Django Job Board

Full Stack Software Engineer (Hybrid) at Provision πŸ†•

Toronto-based construction-AI startup building full-stack product surfaces and AI-forward document-processing systems.

Executive Director at Django Software Foundation πŸ†•

Remote US role running the DSF's fundraising, operations, and public representation as its first Executive Director.

Security Developer at Python Software Foundation

Global remote role triaging CPython/PyPI vulnerabilities and remediating supply-chain attacks alongside the Python Security Response Team.

Senior Full Stack Engineer at Hive Collective

US-only remote, backend-leaning full-stack role at a profitable LegalTech SaaS with an AI-assisted workflow.

Senior Backend Engineer at MyOme

Remote US backend role building portal integrations for patients and providers ordering genetic testing.

Python + TypeScript Engineers at Fusionbox

Remote US consultancy role building Django/React systems for financial workflows and multi-tenant architectures.


Projects

adamchainz/django-msgspec

msgspec-powered utilities for Django.

adamchainz/emojet

Convert, find, and count emoji in Python.

14 Aug 2026 3:00pm GMT

12 Aug 2026

feedDjango community aggregator: Community blog posts

Weeknotes (2026 week 33)

Weeknotes (2026 week 33)

Holidays and the heat wave

I had four weeks of holidays this summer. The timing couldn't have been much better with the heat wave - doing much thinking seems to be impossible anyway. I organized a multi-day feast with a few friends and with much help from others. We built up the site and installations over the course of multiple days and spent some days tearing most of it down afterwards. I started back to the office job physically tired but mentally rested. That's good. I'm really looking forward to seeing the pictures people took.

What's less good is that we're living through the projections which climate scientists warned us about decades ago. Or worse, even, since Switzerland is one of the regions where the temperature increased more than the global average. The member of Switzerland's Federal Council heading the Federal Department of the Environment, Transport, Energy and Communications reportedly said that he didn't expect such intense heat. Of course, it was reported earlier in the same week that the same member was responsible for removing funding for a more resilient forest from the budget for the next fiscal year. This is unfortunately to be expected: he has long been connected to the fossil energy industry. After all, he was also the president of Swissoil and Auto Schweiz. It's really frustrating. None of this is news to climate scientists, and it hasn't been news here either - these posts start in 2005, back when I was studying environmental sciences at ETH with a focus on atmospheric physics.

Scripts for auto-merging dependabot and pre-commit pull requests

I let Claude write some scripts for automatically merging pull requests created by various bots, see here. The script finds pull requests created by a predefined list of bots in a defined list of accounts (organizations or users) and squash-merges them if the CI run is green and there are no conflicts. It's a dry run by default; --apply is required to actually merge anything. It doesn't look at reviews and doesn't care whether a bump is major or minor - I'm relying on the test suites for that.

The ruff 0.16 update was a bit painful because ruff now enables 413 rules by default, up from 59. Recurring themes were warnings about mutable class variables (which are common when using Django), blind except clauses and underspecified dates without time zones, but none of them in scenarios where they actually hurt.

So, instead of just running the merge script, I had to fix up dozens of pyproject.toml files and projects. Oh well, next time will be smooth again.

Releases

Since I've been away from the computer for so long, the list of releases from the start of July onwards is quite short.

django-authlib

django-authlib 0.18 now also supports Microsoft Entra ID logins. The admin integration also has support for Microsoft accounts, not just for Google.

django-content-editor

The django-content-editor 9.0.1 just contains a small fix which avoids submitting the form that allows cloning content between regions when cancelling the dialog.

12 Aug 2026 5:00pm GMT

Storing Django Static and Media Files on Cloudflare R2

This tutorial shows how to configure Django to load and serve up static and media files, public and private, via Cloudflare R2.

12 Aug 2026 3:28am GMT

11 Aug 2026

feedDjango community aggregator: Community blog posts

Duff's device in JavaScript

In 1983, Tom Duff needed to copy memory into an output register faster than his compiler could manage, and wrote the most famous abuse of switch in the history of C. I ported his device to JavaScript and raced it against the plainest possible loop - and the verdict changed with the engine, the engine's version, and the CPU underneath.

Duff’s device in JavaScript

11 Aug 2026 10:00am GMT

Managing email: peace of mind and efficiency with filters

Like probably everybody, I get a lot of email. Newsletters, weekly mails of some shops I want to monitor for handy discounts, linkedIn updates, GitHub/dependabot notifications, some left-over spam, calendar notifications, some Patreon stuff, etc, etc, etc.

Oh, and some real, individual emails! Oh, and spread over two accounts, work and personal.

Goal: less effort and lower maintenance

I want my inbox to be much more empty. I don't need it to be "inbox zero", but having to press the PageDown key four times is a tad much. And everything is mixed together, so if I start to clean up, I'm constantly having to switch between "determining if some GitHub mail can be deleted" and "reading an interesting long newsletter".

I get a low-level feeling of anxiety as I'm bound to miss or neglect important emails. And cleaning up the email is time-consuming as I'm constantly reading and ignoring the same email subjects (and not really dealing with them).

So: I don't want to have my email all mixed up. The inbox should be emptier. And I want to set it up in a way I can easily maintain it.

Solution: automatic filtering + post-holiday cleanup

During my holiday, I barely check my email. So the usual pile of email is even higher. The advantage of the big pile: all different kinds of email are visible in the inbox. If you clean/sort/filter/unsubscribe/whatever now, you'll probably have most different email sources covered.

Solution one: liberally unsubscribe from notifications and mailing lists. I've got enough interesting and diverse sources of information of my own, so I really don't need linkedIn's summaries (there's also more AI spam). I signed up for instagram to see one person's model railway photos, but I don't need the never-ending emails suggesting other people to follow. Also shop mailinglists where I signed up for to get some €5 discount on an order.

Solution two: multiple inbox folders plus automatic filtering. I want to keep my main inbox for the special or sporadic or important emails. So I created folders named inbox_someting and set up filters/rules to move emails into those folders automatically based on sender or subject. For my work email I have four:

inbox_alerts
Notifications about sites being down or colleagues' GitHub AI credits being drained.
inbox_calendar
Microsoft calendar notification emails. Microsoft's email/calendar integration is weird and non-standard and I seem to have to acknowledge meetings on every device I own. My solution now is to acknowledge meetings from this inbox and to use my iphone to actually look at the calendar.
inbox_github
Pull request emails, Dependabot messages, Renovatebot messages, issues being opened.
inbox_teams
Notifications from Microsoft Teams and from Slack.

My personal email has five, at the moment:

inbox_github
Same as for my work email, but then for my personal and open source projects.
inbox_leesvoer
"Leesvoer" is Dutch for "reading fodder". So all those interesting longer newsletters end up here. "Interesting" means "procrastination", so not having them directly in my main inbox keeps me from allowing myself to be distracted. And if I have some time, for instance during a bus trip, I can read some of them at leisure.
inbox_updates
Strava monthly summaries. Apple software update notices. Billing emails. Commercial emails that I'm allowing, like a DIY shop that I like to monitor for discounts. A venue I visit two times a year for a concert. Bank/insurance newsletter. Kickstarter/Bandcamp. Weekly postgress newsletter.
inbox_bnls
I'm moderator (and partially sysadmin) for a Dutch model railway forum (which is often abbreviated "bnls"). Moderation requests and personal messages end up here.
inbox_bnlsundeliver
Somehow I'm getting "email cannot be delivered" error messages from the forum now, so I'm stuffing them all in this folder for later cleanup of emailadresses in the forum. That way they don't clog up my inbox. This is probably a temporary inbox-folder.

Once in a while, I'll quickly look into inbox_updates. I'll read some of them. Most can be deleted. In any case, within no-time the folder will be empty. There's nothing in there I want to keep, normally. That's not something I could do that quickly when it was mixed with everything else in my single main inbox!

Same with quickly going through (and deleting) the GitHub notifications.

Emails that can be more important are in my main inbox. Or in inbox_alerts or inbox_bnls for instance.

Technical details

I'm running our "vanrees.org" email via the German mailbox.org. My work email is sadly Microsoft. I didn't really want to set up filters in a mail program (on my computer), as I want those filters to run continuously, also when I'm on holiday and my computer is stored away. And when the thing is sleeping in my backpack.

So... for my work email, I set up rules in Microsoft Outlook's web interface. There are just a few of them, basically just shuffling all GitHub stuff into inbox_github and meeting info in to inbox_calendar.

For my personal email, things are a bit more complex. Sure, the majority are simple rules, but some have "if from this address but with this subject"-like rules. That's why I landed (after some searching) on imapfilter. I run it on my Linux server via a cronjob. It has a configuration file that I can back up.

Some examples from my config file:

results = account1.INBOX:contain_from('notifications@github.com') +
          account1.INBOX:contain_from('@md.getsentry.com') +
          account1.INBOX:contain_from('noreply@github.com')
results:move_messages(account1.inbox_github)

results = account1.INBOX:contain_to('webmaster@beneluxspoor.net') *
          account1.INBOX:contain_subject('Undelivered Mail Returned to Sender')
results:move_messages(account1.inbox_bnlsundeliver)

And I took the opportunity to filter out some spam messages that somehow managed to slip around the normal spam mechanism:

results = account1.INBOX:contain_from('subaru') +
          account1.INBOX:contain_from('kontaktpush.de')
results:move_messages(account1.Junk)

Conclusion after a few days

So... pretty happy at the moment! My main inbox is much cleaner and keeping up to date is easier. Handling the other inbox-folders is also easier as there's just one category of email in them.

And... as I now have a system, I can expand it. I just looked at my inbox and saw a mail that ought to go into inbox_updates. Adding it, now that I have the system, is just one minute of work. Which will save me many minutes in the years to come!

11 Aug 2026 4:00am GMT

08 Aug 2026

feedDjango community aggregator: Community blog posts

Django: introducing django-msgspec

It's another day, another new package day here. Say hello to django-msgspec, a package of drop-in replacements for Django and Django REST Framework (DRF) components backed by msgspec.

msgspec is a C-based serialization library covering JSON, MessagePack, YAML, and TOML, with optional schema validation through typed Struct classes. Its JSON encoder and decoder are several times faster than the standard library's, which makes django-msgspec a cheap performance win in the parts of Django that handle JSON.

Features

There's a version of JsonResponse:

from django_msgspec.http import JsonResponse


def index(request):
    return JsonResponse({"title": "Hello, world!"})

…a test client with matching test case classes:

from django_msgspec.test import SimpleTestCase


class IndexTests(SimpleTestCase):
    def test_index(self):
        response = self.client.get("/", headers={"accept": "application/json"})
        assert response.status_code == 200
        # response.json() uses msgspec to parse the response body
        assert response.json() == {"title": "Hello, world!"}

…a version of Django's json_script template tag, which is where this whole story began:

{% load django_msgspec %}
{{ sales_by_product_id|json_script:"chart-data" }}

…and a handful of components that you activate purely through settings, with no code changes at all:

SESSION_SERIALIZER = "django_msgspec.sessions.JSONSerializer"

SERIALIZATION_MODULES = {
    "json": "django_msgspec.serializers.json",
    "jsonl": "django_msgspec.serializers.jsonl",
}

REST_FRAMEWORK = {
    "DEFAULT_RENDERER_CLASSES": ["django_msgspec.rest_framework.JSONRenderer"],
    "DEFAULT_PARSER_CLASSES": ["django_msgspec.rest_framework.JSONParser"],
}

That covers session storage and signing, dumpdata / loaddata in both JSON and JSON Lines, and DRF request parsing and response rendering.

Everything encodes with an enc_hook that knows about Django's lazy strings, so translated text passes through as you'd expect. It's all tested against the currently supported versions of Python and Django, with 100% coverage.

DΓ©jΓ  vu?

Only three weeks ago, I introduced django-orjson, a near-identical package backed by the Rust-powered orjson. So yeah, you might be confused why I made another faster-alternative-JSON-package-wrapper package so soon after the first one.

After releasing django-orjson, several folks from the community reached out to me telling me about the issues with orjson and pointing to msgspec instead. Additionally, while trying to roll out django-orjson on a client project, I learned that certain documented behaviours and limitations in orjson were going to be road blockers.

Here's a full list of what I learned:

  • Dictionary keys have to be strings. Take a mapping of object ID to some count:

    >>> import json
    >>> json.dumps({1: "one"})
    '{"1": "one"}'
    
    >>> import orjson
    >>> orjson.dumps({1: "one"})
    Traceback (most recent call last):
      ...
    TypeError: Dict key must be str
    

    JSON objects can only have string keys, so the standard library coerces non-string keys with str(). But orjson refuses to do so, with the justification that the coercion is lossy and the keys will come back as strings when deserialized. Thankfully orjson does have an option here, OPT_NON_STR_KEYS, that opts in to the standard library behaviour, but you gotta know about it!

  • Integers are limited to 64 bits.

    >>> json.dumps(2**64)
    '18446744073709551616'
    
    >>> orjson.dumps(2**64)
    Traceback (most recent call last):
      ...
    TypeError: Integer exceeds 64-bit range
    

    JSON itself sets no limit on number sizes, but RFC 8259 warns that implementations may, and many do-JavaScript, for one, silently loses precision beyond 253. orjson caps integers at 64 bits, matching native integer types, while Python's arbitrary-precision integers mean the standard library will happily emit larger values.

    In this case, orjson is probably more correct, but it is lacking an option to restore compatibility if required. (I didn't encounter any use case for massive numbers myself.)

  • There's nowhere to report problems.

    The orjson README sayeth:

    There is no open issue tracker or pull requests due to signal-to-noise ratio.

    I have plenty of sympathy for that decision, having felt the weight of my own project's issue trackers backing up. But it does mean that when you hit any problems, you can't check whether it's known, follow a fix, or contribute one. Your options are to read the CHANGELOG and hope, or to work around it yourself.

  • No sub-interpreter support, ever.

    The README also says:

    orjson does not and will not support PyPy, embedded Python builds for Android/iOS, or PEP 554 subinterpreters.

    I kinda missed this one when adopting orjson, but for me it's a bit of a concern. I think subinterpreters could support some cool use cases, like fast parallel test runners, and from my experience writing extension packages, support for them does not add much overhead.

    And this is not a soft limitation-you can't even import orjson in a sub-interpreter, let alone serialize anything:

    >>> from concurrent import interpreters
    >>> interp = interpreters.create()
    >>> interp.exec("import orjson")
    Traceback (most recent call last):
      ...
    concurrent.interpreters.ExecutionFailed: ImportError: module orjson.orjson does not support loading in subinterpreters
    

    It's a shame that the orjson maintainer advertises such a hard line here.

  • No free-threading support yet.

    orjson currently publishes no wheels for free-threaded Python, which is now "stable" as of Python 3.14. On a free-threaded build, you're forced to compile the Rust package from source, which is a bit of an adoption blocker for large teams.

  • Pydantic decided against it, on trust grounds.

    Back in 2019, a contributor opened a pull request to use orjson in Pydantic, and Samuel Colvin declined it. His stated reasons were:

    1. orjson's author gives no name or personal details on GitHub.
    2. The author had privately emailed Sam asking for the integration, and he was "surprised and somewhat worried by the hostile response I got when I made his/her request public".
    3. The compiled wheels for the package could potentially contain malicious code, which seems like more of a risk given the author's anonymity.

    He was careful to add: "Let me make it clear: I'm not accusing <the maintainer> of anything, I'm 99% certain that his/her intentions are honourable."

    I have the same feelings, now that I'm aware of all the (public) details of orjson. Pseudonymity is entirely legitimate and plenty of excellent software is written under a handle, and this was seven years ago. But stack it up with the closed issue tracker, and it does mean that installing orjson means trusting a compiled binary from a maintainer who has chosen not to engage in public at all.

msgspec's advantages

msgspec answers the questions raised by the above points against orjson:

  • It handles numerical keys the way the standard library does:

    >>> import msgspec.json
    >>> msgspec.json.encode({1: "one"})
    b'{"1":"one"}'
    
  • It encodes and decodes large integers:

    >>> import msgspec.json
    
    >>> msgspec.json.encode(2**64)
    b'18446744073709551616'
    
    >>> msgspec.json.decode(b"18446744073709551616")
    18446744073709551616
    
  • It has an open issue tracker.

  • msgspec fails to import in sub-interpreters right now, but the issue is being worked on by the maintainer and contributors.

  • msgspec publishes free-threading compatible wheels today.

  • The creator is not anonymous and the project is now maintained by a group in a GitHub organization, featuring at least Nikita Sobolev who I have known online from other open source projects (especially django-stubs).

And most importantly, msgspec's encoding and decoding are in the same performance ballpark as orjson's!

msgspec has standard library incompatibilities too

By the way, msgspec isn't fully compatible with json-here are the differences that I know about.

  1. Non-finite floats are encoded as null rather than the standard library's NaN and Infinity:
>>> json.dumps(float("inf"))
'Infinity'

>>> msgspec.json.encode(float("inf"))
b'null'

I'd call this an improvement, since Infinity isn't valid JSON and other parsers will reject it. But it is a change, so if you rely on round-tripping those values, take note.

  1. msgspec also only coerces keys that are string-like or number-like, so booleans and None are still rejected:
>>> json.dumps({None: "nothing"})
'{"null": "nothing"}'

>>> msgspec.json.encode({None: "nothing"})
Traceback (most recent call last):
  ...
TypeError: Only dicts with str-like or number-like keys are supported

Such keys should be rarer than numbers in practice.

What might come next in django-msgspec

django-msgspec covers the same ground as django-orjson today, but msgspec is a broader library than orjson, so there's more potential for future development.

First, msgspec's typed container class, Struct, lets you decode and validate in a single pass:

>>> import msgspec
>>> class Sale(msgspec.Struct):
...     product_id: int
...     count: int
...
>>> msgspec.json.decode(b'{"product_id": 1, "count": 2}', type=Sale)
Sale(product_id=1, count=2)

>>> msgspec.json.decode(b'{"product_id": "one", "count": 2}', type=Sale)
Traceback (most recent call last):
  ...
msgspec.ValidationError: Expected `int`, got `str` - at `$.product_id`

This could be useful for combining with Django views, DRF parsers, or even forms.

Second, msgspec can serialize and deserialize other data types, so they might be worth integrating.

Happy to take suggestions on the design here, on the issue tracker.

Fin

Please try out django-msgspec today and let me know how it goes.

May all your messages be to specification,

-Adam

08 Aug 2026 2:24am GMT

07 Aug 2026

feedDjango community aggregator: Community blog posts

Issue 349: Django 6.1 and a DSF Executive Director

News

Call for applicants for a Django Executive Director

The DSF is hiring its first Executive Director to run fundraising, operations, staff, and legal/compliance for the Foundation. The role is US-based (no visa sponsorship). Applications (resume, optional cover letter, and a vision statement) are due September 14, 2026.

Django 6.1 released

Django 6.1 introduces model field fetch modes, database-level delete options for ForeignKey.on_delete, and dictionary-based email settings. Django 6.0 is now out of mainstream support and receives security and data loss fixes only until April 2027, so plan your upgrade before then.

Thank you to release manager Jacob Walls and the entire Django team on another happy feature release.


Releases

Django security releases issued: 6.0.8 and 5.2.17

The releases address high-severity spatial lookup flaws that could write files or make network requests, plus denial-of-service risks in language and geometry handling and potential XSS from unsafe admin URLField values. Upgrade to Django 6.0.8 or 5.2.17 as soon as possible.

Python 3.15.0 candidate 1 is here!

Python 3.15.0 candidate 1 is available for testing, giving projects a release candidate against which to check compatibility and build wheels.

Python 3.14.7 and 3.13.15 are now available!

Python 3.14.7 and 3.13.15 are available as bug-fix releases, so update your installations.


Wagtail CMS News

An agent-heavy roadmap for 2026

A standards-based look at what agent readiness requires, without the usual hype.


Sponsored Link

Find and fix Python errors faster - for free - with Honeybadger

When something breaks in production, logs tell you something happened. Honeybadger tells you why.

Honeybadger filters out noise and transforms your Python logs into context-rich issues so you can stop guessing and ship the fix faster.


Django Fellow Reports

Django Fellow Report - Jacob

Highlights this week included clearing release blockers for 6.1 and making the parallel test runner more fault tolerant. That means lots of tickets triaged, reviewed, and authored!

Django Fellow Report - Natalia

This week I prioritized time-sensitive security work πŸ₯·, including finalizing patches, preparing and validating backports, and sending pre-notifications ahead of the release. Alongside that, I focused on Tim's "Sprint quickstart" PR πŸƒβ€βž‘οΈ and attended meetings 🎧. Otherwise, I spent time preparing my DjangoCon US talk (it is coming together well, even if I am a bit wary of expectations around my htmx expertise 🎀).

Django Fellow Report - Sarah

Focus of the week was mostly around helping finalize the security release and reviewing the GSOC Selenium to Playwright migration (which is looking in good shape!).


Articles

Django Claude Skills

Mariatta built a set of Django house rules for Claude to follow in her own projects, covering formatting (black, isort, djlint, flake8), full test coverage as a merge requirement, has_perm() over group-membership checks for permissions, and a single Markdown email template that renders both text and HTML. She's explicit that these are personal conventions, not Django community consensus, and that a project's own style wins when contributing elsewhere. She published it as an Astro static site rather than plain Markdown, since she'd rather read the rules in a browser than as agent-only files.

What I love about Django

The best parts of Django are the ones you stop noticing - a tour of the abstractions that have given Buttondown the most leverage over the years.

Django Doesn't Have to Feel Old: Modernize Your Frontend with Vite

A walkthrough of wiring django-vite into a Django project: hot module replacement so CSS edits show up without a full page reload, explicit imports instead of global script-tag collisions, and a vite_asset template tag that resolves to the dev server or hashed production files depending on DEBUG.

Django 6 isn't a revolution. And that's exactly why I like it.

An experienced developer's take on years of building applications with Django and why the latest update continues a philosophy that many modern frameworks seem to have forgotten.

Python: how time-machine is O(1) where freezegun is O(n)

The comparison explains why time-machine avoids freezegun's O(n) slowdown when mocking dates and times, building on benchmarks that found it 100 to 200 times faster across two project sizes.

Core Dispatch #9

Python 3.15.0 release candidate 1 is out, followed by maintenance releases Python 3.14.7 and 3.13.15. The latest Core Dispatch also tracks two new PEPs entering the queue.

Devtools must be open source

Agents can now rebase your local tweaks against upstream automatically, so personalizing a tool costs a prompt instead of an ongoing maintenance burden. That's the case for why closed-source tools like Claude Code can't be personalized the way open alternatives like Codex or Pi can.


DjangoCon US

Save 10% on DjangoCon US 2026 registration

DjangoCon US 2026 runs August 24 to 28 in Chicago, now under three weeks away. Register through our link to take 10% off. Time is running out to get your ticket. Get yours before they are gone!


Events

Chicago Like a Local: Things to Do During DjangoCon US 2026 (Part 1)

Conference chair Keanya Phelps rounds up Italian beef at Mr. Beef and Al's, ramen and vinyl records at Wax in West Town, shuffleboard at Electric Shuffle, and rooftop lake views at Offshore on Navy Pier. She also flags the free Chicago House Music Festival in Millennium Park, August 27 to 30, which overlaps the last days of the conference.


Django Job Board

A few openings this week, including Django and Python Software Foundation roles.

Executive Director at Django Software Foundation πŸ†•

Security Developer at Python Software Foundation

Senior Full Stack Engineer at Hive Collective

Senior Backend Engineer at MyOme

Python + TypeScript Engineers at Fusionbox


Videos

PyCon US 2026

PyCon US 2026 videos are up.


Projects

wemake-services/django-modern-rest

Modern REST framework for Django with types and async support!

wsvincent/django-skills

Unofficial Django skills based on Django docs and community best-practices.

07 Aug 2026 3:00pm GMT

04 Aug 2026

feedDjango community aggregator: Community blog posts

`break`/`continue` is the new `goto`

"Go To Statement Considered Harmful" is one of the most-quoted titles in programming, and almost nobody reads past it. The argument underneath is narrower than the slogan it became - and the reflex it bred, avoiding every jump, produces code worse than the goto it was meant to replace.

break/continue is the new goto

04 Aug 2026 10:00am GMT

03 Aug 2026

feedDjango community aggregator: Community blog posts

Running Headscale on my own infra (and finally killing my WireGuard setup)

Hello everyone πŸ‘‹

This one started in the dumbest possible way: I wanted to check on my 3D printer from outside my house.

I have a Bambu printer running in LAN-only mode, and I use OctoApp to control it from my phone. Works great at home. Useless the moment I leave. The usual answer is OctoEverywhere, which is a lovely project, but it means my printer traffic goes through someone else's servers, and I already run enough infrastructure that this felt silly.

So I went looking for a way to just… have my home network with me. And I ended up rebuilding my entire remote access setup in an afternoon.

What I had before

My old setup was, in hindsight, a bit of a Rube Goldberg machine:

Two Pi-holes. Two address ranges. Two mental models of "where am I right now". It worked, but every time I added a service I had to think about which side of the tunnel it lived on.

What I actually wanted was much simpler: when I'm away, I want my phone to behave exactly like it's sitting on my home WiFi. Same IPs, same DNS, no difference at all.

That's a mesh VPN, and the nicest one is Tailscale.

I don't trust free

Tailscale is excellent. I want to say that first, because what follows is going to sound like criticism and it isn't. The client is great, the free tier is generous, and for most people it's the right answer.

But every time I look at a free tier this good, I catch myself doing the same mental arithmetic: someone is paying for this, and it isn't me. Tailscale is a venture-funded company. Free tiers built on top of venture funding have a well-documented lifecycle, and the last chapter is rarely "and it stayed free and generous forever". The limits shrink, or a device cap appears that you're already over, or the company gets acquired by someone with different ideas.

I'm not predicting that Tailscale does any of this. I have no reason to think they will. But I built my remote access on a WireGuard tunnel I control, and moving to something where a company I don't control holds the keys to my entire home network felt like a downgrade, even if the software is better.

I've also been burned recently enough that I'm not in a trusting mood. I wrote a whole angry post a few months ago about paying $100 a month for a service and getting less than 24 hours of notice before it changed underneath me. That was a paid tier. If that's what happens when I'm a customer, I'm not going to build my house on a free one.

So: I like the idea, I don't trust the arrangement. Which is exactly the situation self-hosting exists for.

Enter Headscale.

What Headscale actually is

Headscale is an open source implementation of the Tailscale control server. Your devices still run the official Tailscale client, they just point at your server instead of Tailscale's.

The important thing to understand is what the control server does and doesn't do. It handles key exchange, device registration, ACLs, and DNS settings. It does not sit in the middle of your traffic. Once two devices know about each other, they talk directly. So Headscale being a small container on a cheap VPS is completely fine.

I put it on the same Hetzner box that was already running WireGuard, because it already has a public IP and I was going to be deleting the WireGuard side anyway.

The Docker Compose setup

Headscale is CLI-first, which is fine, but I got tired of typing docker exec headscale headscale ... within about four minutes. So I also added Headplane, a web UI for it.

Directory structure first:

mkdir -p ~/headscale/config/{headscale,headplane}
cd ~/headscale
wget -O config/headscale/config.yaml https://raw.githubusercontent.com/juanfont/headscale/main/config-example.yaml
wget -O config/headplane/config.yaml https://raw.githubusercontent.com/tale/headplane/main/config.example.yaml

And the compose file:

services:
 headplane:
 image: ghcr.io/tale/headplane:latest
 container_name: headplane
 restart: unless-stopped
 ports:
 - "3003:3000"
 volumes:
 - ./config/headplane/config.yaml:/etc/headplane/config.yaml
 - ./config/headplane/lib:/var/lib/headplane
 # Shared path to the Headscale config. This has to match
 # `headscale.config_path` in the Headplane config.
 - ./config/headscale/config.yaml:/etc/headscale/config.yaml
 - /var/run/docker.sock:/var/run/docker.sock:ro

 headscale:
 image: headscale/headscale:latest
 container_name: headscale
 restart: unless-stopped
 command: serve
 labels:
 # Absolutely necessary for Headplane to find Headscale.
 me.tale.headplane.target: headscale
 ports:
 - "8083:8080"
 volumes:
 # Same host path in both containers. This matters.
 - ./config/headscale/config.yaml:/etc/headscale/config.yaml
 - ./config/headscale/lib:/var/lib/headscale

Nginx handles TLS and public exposure, same as every other service on that box. If you're not putting a firewall in front of these, bind the ports to 127.0.0.1 instead ("127.0.0.1:8083:8080"), otherwise Docker happily opens them to the internet and walks around UFW while it's at it.

Note the me.tale.headplane.target label on the Headscale container. That's how Headplane finds it, and it's how Headplane restarts Headscale when you change DNS settings from the UI. Note also that both containers mount the Headscale config from the same host path. Headplane reads it directly, so if the paths drift you get a UI that shows you stale settings.

Headscale config

The parts of config/headscale/config.yaml that matter:

server_url: https://headscale.example.com
listen_addr: 0.0.0.0:8080
metrics_listen_addr: 127.0.0.1:9090

database:
 type: sqlite3
 sqlite:
 path: /var/lib/headscale/db.sqlite

dns:
 magic_dns: true
 base_domain: ts.example.com
 override_local_dns: true
 nameservers:
 global:
 - 1.1.1.1

Leave nameservers.global pointing at a public resolver for now. We'll swap it for the Pi-hole later, once the Pi-hole has joined the network and has an address to point at.

base_domain has to be different from your server_url domain, otherwise Headscale refuses to start.

Headplane config

In config/headplane/config.yaml:

server:
 host: "0.0.0.0"
 port: 3000
 base_url: "https://headplane.example.com"
 cookie_secret: "<32 char random string>"
 cookie_secure: true
 data_path: "/var/lib/headplane"

headscale:
 url: "http://headscale:8080"
 config_path: "/etc/headscale/config.yaml"
 api_key: ""

Generate the cookie secret with openssl rand -hex 32.

The url is the internal Docker address: container name, container port. Not whatever port you mapped on the host. The two containers talk over the compose network.

Then start Headscale on its own, create a user and an API key:

docker compose up -d headscale
docker exec headscale headscale users create myuser
docker exec headscale headscale apikeys create --expiration 90d

Paste that key into api_key and bring up Headplane. The key is only shown once, so if you lose it, just make another one. apikeys list only shows the prefix.

Nginx

Two vhosts, nothing exotic. But the Headscale one needs WebSocket passthrough, and this is the first place I lost time (more on that below):

location / {
 proxy_pass http://127.0.0.1:8083;
 proxy_http_version 1.1;
 proxy_set_header Upgrade $http_upgrade;
 proxy_set_header Connection $connection_upgrade;
 proxy_set_header Host $host;
 proxy_set_header X-Real-IP $remote_addr;
 proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
 proxy_set_header X-Forwarded-Proto https;
 proxy_redirect off;
 proxy_read_timeout 5m;
}

Headplane is a boring proxy block, no special headers needed. Certbot for certs, as usual.

Pi-hole as the tailnet DNS

This is my favourite part of the whole setup, and the reason I bothered.

Join your home Pi-hole to the network like any other device:

curl -fsSL https://tailscale.com/install.sh | sh
sudo tailscale up --login-server https://headscale.example.com
tailscale ip -4

Take that 100.x.x.x address, put it in Headscale's dns.nameservers.global, and restart the container.

Now every device on the tailnet uses my home Pi-hole for all DNS. Which means:

Neither of those is new, I had both with the old WireGuard setup. The difference is that I now get them from one Pi-hole instead of two, and I didn't have to think about it. The Pi-hole joined the tailnet, I pointed Headscale at it, and every device I add from now on inherits both for free.

override_local_dns: true is what forces this. Without it, your phone keeps using whatever DNS the carrier hands it, the tunnel works fine, and your internal domains silently fail to resolve.

The subnet router

A mesh VPN only connects the devices that are on it. My printer can't run a Tailscale client. Neither can most of the stuff I care about.

The fix is a subnet router: one device on your LAN that advertises the whole network to everyone else. I used the Pi-hole box, since it was already joined:

sudo tailscale up --login-server https://headscale.example.com \
 --advertise-routes=192.168.0.0/24

Two things have to happen after this or nothing works, and I hit both.

First, IP forwarding:

echo 'net.ipv4.ip_forward = 1' | sudo tee -a /etc/sysctl.d/99-tailscale.conf
echo 'net.ipv6.conf.all.forwarding = 1' | sudo tee -a /etc/sysctl.d/99-tailscale.conf
sudo sysctl -p /etc/sysctl.d/99-tailscale.conf

Second, and this is the one that got me: routes are double opt-in. The node advertises, and then the control server has to approve. Advertising alone does nothing.

docker exec headscale headscale nodes list-routes
docker exec headscale headscale nodes approve-routes --identifier 1 --routes 192.168.0.0/24

The moment I ran that second command, everything worked. Printer, internal domains, random machines on my LAN, all reachable from my phone on cellular.

Clients

You don't need a special app. The official Tailscale clients all support custom control servers.

On Android, install from Play Store or F-Droid, go to "Accounts", tap the kebab menu, and select "Use an alternate server". Enter your Headscale URL and log in.

On iOS it's slightly more buried: install from the App Store, tap the account icon, "Log in", then use the options menu to pick "Use custom coordination server".

macOS is the one with a real trap. Use the standalone build, not the Mac App Store one, because the App Store version is sandboxed and fights you on custom control servers. brew install tailscale works, or grab the standalone package from Tailscale's download page. Then:

sudo tailscale up --login-server https://headscale.example.com
tailscale set --accept-routes

That --accept-routes is easy to forget. Without it the client joins fine and then can't see anything on your LAN.

The gotchas that cost me the most time

Four things ate most of my afternoon. All of them had error messages that pointed somewhere other than the actual problem.

Headscale listening on 127.0.0.1 inside its own container

Headplane kept logging this:

Error while validating API key: [object Object]

I regenerated that API key three times. The key was fine. The real problem was one line above in the Headscale logs:

INF listening and serving HTTP on: 127.0.0.1:8080

127.0.0.1 inside a container means that container only. Not the compose network, not the other container sitting right next to it. Headplane literally could not open a connection, so it couldn't validate anything, and reported that as an auth failure.

Set listen_addr: 0.0.0.0:8080 in the Headscale config. Your host port mapping is a separate concern and keeps working the same.

Nginx eating the WebSocket upgrade

Clients wouldn't connect, and Headscale said:

WRN no upgrade header in TS2021 request. If headscale is behind a reverse proxy,
make sure it is configured to pass WebSockets through.

At least this error tells you exactly what's wrong. My nginx config was a copy-paste of the same reverse proxy block I use for every other service on that box, which has no Upgrade handling because nothing else needs it.

The commonly recommended fix uses a map block in the http context:

map $http_upgrade $connection_upgrade {
 default upgrade;
 '' close;
}

The idea is that plain HTTP requests to the same vhost get Connection: close instead of a bogus Connection: upgrade. If you put that map somewhere nginx doesn't load it into the http context, you get unknown "connection_upgrade" variable and nothing starts.

I'll be honest: I got that error, commented the Connection line out entirely to keep moving, and it worked. It's on my list to go back and do properly, because "it worked when I removed the correctness" is not a state I enjoy leaving things in.

The routes CLI moved

Every guide and blog post out there tells you to run:

headscale routes list
headscale routes enable -r 1

On 0.29 that gives you unknown command "routes". It's now under nodes:

headscale nodes list-routes
headscale nodes approve-routes --identifier 1 --routes 192.168.0.0/24

Related: --user wants a numeric ID now, not a username. headscale users list gives you the number.

When in doubt, headscale --help is more current than anything you'll find in a search result. I lost a few minutes to blog posts written against older versions before I just asked the binary.

Pi-hole ignoring the tailnet

Tunnel up, subnet routes approved, ping 1.1.1.1 working, and google.com resolving to nothing.

Pi-hole's default interface listening behaviour doesn't answer queries arriving on the tailscale0 interface. Settings, then DNS, then set it to listen on all interfaces and permit all origins. Or scope it to the tailnet CIDR if you want to be tidier about it than I was.

The bash alias

Small thing, big quality of life improvement:

alias headscale='docker exec headscale headscale'

Now every command in every tutorial you find online works verbatim, without mentally prefixing the docker part every time. Just remember it's there if you ever install the real binary on the same box, or you'll spend an entertaining twenty minutes wondering why the local install ignores your config file.

What I still need to do

I'm not going to pretend this is finished.

The old WireGuard tunnel and its dedicated Pi-hole are still running. Headscale does everything they did, but I'm leaving them up until I've gone through the Nginx Proxy Manager config and confirmed nothing still points at a 10.0.0.x address. Deleting infrastructure at 2 AM after a successful afternoon is how you learn what depended on it.

Was it worth it?

Yes, and for reasons beyond the printer.

The setup collapsed two Pi-holes and two IP ranges into one mental model. My phone now behaves identically at home and on cellular. Every internal service I add from now on works remotely for free, without port forwarding or a new proxy host or any thought about which side of a tunnel it lives on.

And no ports are open to my LAN. The only publicly reachable thing is the Headscale control server, which hands out keys and doesn't carry traffic.

The total cost was one afternoon, most of which was spent on four error messages that pointed at the wrong thing. Hopefully this post saves you those.

If you want to check the printer thing specifically: Bambu printers in LAN-only mode expose MQTT on 8883 and FTPS on 990. There's no web UI, so don't waste time typing the IP into a browser like I did. Once you're on the tailnet, OctoApp just takes the same local IP and access code you use at home.

See you in the next one!

03 Aug 2026 5:00am GMT