Web Development · Frameworks
Django 6.1: Fetch Modes, FK Deletes, and MAILERS
Django 6.1 adds QuerySet.fetch_mode() to catch accidental N+1s, ON DELETE pushed into the database, and one MAILERS setting replacing loose EMAIL_ config.
Prathviraj Singh
6 min read
Sponsored
Django 6.1 shipped August 5, and the three changes worth actually planning around are not the ones a changelog skim would highlight. Template partials and background tasks got the attention when 6.0 landed in December. This time, the interesting work is quieter: a way to catch N+1 queries before they ship, a way to make deletion cascades a database guarantee instead of an application-code convention, and a cleanup of a setting that has accreted cruft since Django’s earliest releases.
Fetch modes: turning a silent extra query into a decision
Every Django developer has shipped this bug at least once. You call .only("title") or .defer("body") on a queryset, then somewhere downstream a template or a serializer touches .body anyway. Django quietly issues a fresh query per instance to fetch it, your page works, and nobody notices until a profiler shows five hundred queries on a page that should have run three.
QuerySet.fetch_mode() gives you a way to make that behavior explicit instead of implicit:
from django.db.models import FETCH_ONE, FETCH_PEERS, RAISE
# Default-ish: fetch the missing field for this instance only, as before
Article.objects.only("title").fetch_mode(FETCH_ONE)
# Batch it: one query fetches the missing field for every instance
# in the queryset, roughly equivalent to an automatic prefetch_related()
Article.objects.only("title").fetch_mode(FETCH_PEERS)
# Refuse: touching an unloaded field raises FieldFetchBlocked
# instead of issuing a query at all
Article.objects.only("title").fetch_mode(RAISE)
FETCH_PEERS is the one worth adopting deliberately. If you know a template is going to touch a deferred field for every row in a list view, FETCH_PEERS turns N silent queries into one. That’s the same shape of win you get from select_related() or prefetch_related(), except it applies to a field you deferred rather than a relation you’re following, a gap those two methods never covered.
RAISE is the one worth reaching for in tests and code review. Wrapping a critical queryset in fetch_mode(RAISE) during development turns “why is this endpoint doing 40 queries” from a profiler mystery into an exception with a stack trace pointing at the exact line that touched the wrong field. It’s the same instinct behind structured logging with correlation IDs: make the failure loud at the point it happens, not discoverable three steps downstream.
Pushing ForeignKey deletes into the database
on_delete=CASCADE has always meant “Django will load the related rows and delete them in Python.” That’s correct, but it’s also why a CASCADE delete on a table with millions of dependent rows can be slow and memory-hungry: Django has to know about every row it’s cascading to.
6.1 adds three database-level equivalents:
from django.db import models
class LineItem(models.Model):
order = models.ForeignKey(
"Order",
on_delete=models.DB_CASCADE, # emits an ON DELETE CASCADE clause
)
DB_CASCADE, DB_SET_NULL, and DB_SET_DEFAULT emit the equivalent SQL ON DELETE clause and let the database enforce it, instead of Django loading rows into Python first. Two practical differences follow from that. Deletes are faster, because the database doesn’t hand rows back to Django only to delete them again. And the constraint holds even for rows Django never loaded, which matters more than it sounds: a raw SQL delete, a database console session, or another service writing to the same database will still respect the cascade, because it’s enforced at the schema level rather than in your ORM’s code path.
The tradeoff is the one you’d expect from moving logic out of your application: pre_delete and post_delete signals don’t fire for rows removed this way, because Django never loads them to know they existed. If you have signal handlers doing cleanup work on delete, cache invalidation, audit logging, notification dispatch, keep the existing CASCADE and accept the Python-side cost, or move that cleanup into a database trigger. This isn’t a default change; existing on_delete values are untouched, so nothing breaks on upgrade. It’s a new option for the specific case where you want the database to hold the guarantee.
MAILERS: one setting instead of a dozen
Django’s email configuration has been a flat pile of EMAIL_HOST, EMAIL_PORT, EMAIL_HOST_USER, EMAIL_HOST_PASSWORD, EMAIL_USE_TLS, and half a dozen more module-level settings since roughly the framework’s earliest releases. 6.1 introduces MAILERS, a single dictionary that groups them:
# settings.py
MAILERS = {
"default": {
"BACKEND": "django.core.mail.backends.smtp.EmailBackend",
"HOST": "smtp.example.com",
"PORT": 587,
"USE_TLS": True,
"HOST_USER": "no-reply@example.com",
},
}
The legacy EMAIL_* settings still work in 6.1, so this isn’t a forced migration. What does change, and what’s easy to miss if you only skim the release notes, is a stricter contract for custom email backends. BaseEmailBackend.__init__() used to silently swallow unrecognized keyword arguments. In 6.1 that now triggers a deprecation warning, and it becomes a hard error in Django 7.0. If your team maintains a custom EmailBackend subclass that passes **kwargs through without fully consuming the ones it supports, this is worth an audit now rather than a surprise on the next major upgrade. It’s a small thing, but it’s the kind of “consume all your kwargs before forwarding the remainder” hygiene issue that stays invisible for years until a version bump turns it into a startup crash.
The context: 6.0 aging out
6.1’s release also marks the point where Django 6.0 stops receiving new features and moves to security-and-data-loss-fixes-only support, which runs through April 2027. If you migrated off Celery onto Django 6.0’s built-in Tasks framework earlier this year, that work carries forward unchanged. Nothing about 6.1 touches Tasks.
None of the three changes here demand an immediate migration. Fetch modes and database-level deletes are opt-in additions you can adopt where they solve a real problem you already have, not defaults that change existing behavior. MAILERS is additive, not a required rewrite of your email settings. The upgrade itself is routine: bump the dependency, run your test suite, and if you maintain a custom email backend, check that it isn’t quietly dropping kwargs it should be consuming. For a framework this mature, that’s what a healthy minor release looks like: nothing forces your hand, but three of the additions are worth reaching for the next time you’re debugging an N+1 query or designing a schema that needs the database, not just Django, to guarantee referential integrity.
Frequently asked questions
- What does QuerySet.fetch_mode() actually change?
- It controls what happens when you access a model field that the original query didn't load, something that normally triggers a silent extra query per instance. FETCH_ONE is close to today's default behavior. FETCH_PEERS fetches the missing field for every instance in the queryset in one batched query, similar to calling prefetch_related() after the fact. RAISE refuses the implicit query entirely and raises FieldFetchBlocked, which is the mode you want in a code review to catch accidental N+1s before they ship.
- Do I need to change my ForeignKey definitions to use DB_CASCADE?
- No, it's opt-in. CASCADE, SET_NULL, and the other existing on_delete values keep working exactly as before, handled in Django's own code after it runs the query. DB_CASCADE, DB_SET_NULL, and DB_SET_DEFAULT are new alternatives that push the same logic into the database via an ON DELETE clause, which is faster for large deletes and enforces the constraint even for rows Django never loads into Python. Adopt them where you want database-enforced integrity, not as a required migration.
- Will MAILERS break my existing email configuration?
- Not immediately. The legacy EMAIL_* settings (EMAIL_HOST, EMAIL_PORT, EMAIL_HOST_USER, and so on) still work in 6.1. What changes is that custom email backend subclasses that accept **kwargs and silently drop unrecognized ones now get a deprecation warning instead of silent data loss, and that warning becomes a hard error in Django 7.0. If you maintain a custom backend, audit it now rather than waiting for the upgrade that breaks it.
- What happens to Django 6.0 now that 6.1 is out?
- 6.0 moves out of mainstream feature support and into security-and-data-loss-fixes-only mode, which runs through April 2027. It isn't abandoned, but new features stop landing there. If you're still on 6.0, you have time to plan the 6.1 upgrade rather than needing to do it immediately, but you shouldn't expect further feature releases on the 6.0 branch.
Sources
Sponsored
More from this category
More from Web Development
Sponsored
Discussion
Join the conversation.
Comments are powered by GitHub Discussions. Sign in with your GitHub account to leave a comment.
Sponsored