Django 6.1 adds two ORM changes with a shared goal: let the database do useful work without making the application opaque. Fetch modes change what happens when a field is loaded on demand. DB_CASCADE moves a cascade delete from Python to an SQL constraint. Both can remove a great deal of work, but enabling them without examining their side effects is risky.

The Django 6.1 release notes frame these APIs as optimizations. In practice, they change where the work happens. That is the key point to understand before upgrading.

Fetch modes handle fields that were not loaded

A queryset does not always load everything. A ForeignKey is resolved on first access if it was not included with select_related(). Likewise, only() and defer() intentionally leave fields out of an instance.

Until Django 6.0, accessing a missing relation behaved the same way everywhere: one query per instance. Django 6.1 makes that decision explicit through QuerySet.fetch_mode().

from django.db import models


books = Book.objects.fetch_mode(models.FETCH_PEERS)

for book in books:
    print(book.author.name)

The mode is not limited to ForeignKey fields. It also applies to OneToOneField fields, their reverse accessors, fields deferred with only() or defer(), and generic relations. Django also copies the mode to related objects it loads, which matters when code traverses several relationships.

FETCH_ONE preserves the existing behavior

FETCH_ONE is the default. The first book.author loads the author for that book only. A loop over 100 books can therefore issue 101 queries: one for books and one for every missing author.

books = Book.objects.fetch_mode(models.FETCH_ONE)

for book in books:
    print(book.author.name)  # potentially 1 + N queries

This remains a good choice when only one instance will be used, or when accessing the other relations is unlikely. It also preserves an important property: accessing a relationship stays local and predictable.

FETCH_PEERS turns N+1 into a batch fetch

FETCH_PEERS makes one assumption: if code reads a field on one object in a batch, it will probably read it on the others. On the first book.author access, Django fetches missing authors for every instance that originated from the same queryset.

For the preceding example, that normally means two queries: one for books, then one for authors. It is an on-demand fetch, similar to prefetch_related(), but it is triggered only because code actually needs the relation.

books = Book.objects.fetch_mode(models.FETCH_PEERS)

for book in books:
    render_book(book.title, book.author.name)

This helps in rendering layers, serializers, and business rules where the exact relations accessed are hard to predict. Django tracks peers through weak references so discarded instances do not remain in memory.

It is not a reason to stop designing querysets. When a view will always display an author, select_related("author") still communicates intent better and avoids the second query. FETCH_PEERS is most useful when access is conditional or spread across layers, not as a blanket replacement for targeted optimization. For choosing between joins and explicit prefetching, see select_related() versus prefetch_related().

It also does not replace collection prefetching. Reverse manager queries, such as book.review_set.all(), are not affected by fetch modes. When a one-to-many or many-to-many collection is rendered in a loop, prefetch_related() is still the right tool.

FETCH_RAISE makes a regression visible

In a critical path, the best N+1 is often the one that fails a test. FETCH_RAISE raises FieldFetchBlocked when an access would cause an implicit query.

from django.db import models
from django.core.exceptions import FieldFetchBlocked


books = Book.objects.fetch_mode(models.FETCH_RAISE)

for book in books:
    print(book.author.name)  # FieldFetchBlocked unless author was loaded

Use it in a performance test, a large export, or a view where every relationship must be declared. The fix is to load what is needed explicitly with select_related("author"), prefetch_related("reviews"), or by including a field in only().

A manager can set a default policy too:

class BookManager(models.Manager):
    def get_queryset(self):
        return super().get_queryset().fetch_mode(models.FETCH_PEERS)


class Book(models.Model):
    author = models.ForeignKey("Author", on_delete=models.CASCADE)
    objects = BookManager()

Reserve that pattern for models whose relations are genuinely explored unpredictably. Setting it globally may fetch unnecessary data in commands, tasks, and APIs whose profile differs from an HTML page.

DB_CASCADE finally delegates cascades to the database

The existing models.CASCADE is driven by Django’s deletion collector. It emulates an ON DELETE CASCADE constraint and loads the necessary objects before deleting them. pre_delete and post_delete signals are sent for every deleted object, even though Model.delete() is not called on every child.

Django 6.1 introduces models.DB_CASCADE: the migration creates an SQL ON DELETE CASCADE constraint and the database deletes dependent rows itself.

from django.db import models


class Order(models.Model):
    customer = models.ForeignKey(
        "Customer",
        on_delete=models.DB_CASCADE,
        related_name="orders",
    )

When deleting a customer with a large number of orders, the difference can be substantial. Django no longer has to load orders to orchestrate the cascade. The database runs the operation next to its indexes and constraints.

The trade-off is a domain decision: DB_CASCADE does not send pre_delete or post_delete signals for rows deleted by the database. Auditing, file deletion, cache invalidation, or event publication implemented in those signals will no longer run.

The three DB_* options and their constraints

The available options are DB_CASCADE, DB_SET_NULL, and DB_SET_DEFAULT. They match the Python-managed CASCADE, SET_NULL, and SET_DEFAULT strategies, but the database performs the action.

class Ticket(models.Model):
    assignee = models.ForeignKey(
        "User",
        null=True,
        on_delete=models.DB_SET_NULL,
    )


class Event(models.Model):
    project = models.ForeignKey(
        "Project",
        db_default=1,
        on_delete=models.DB_SET_DEFAULT,
    )

DB_SET_NULL still requires null=True. DB_SET_DEFAULT requires a db_default, not merely a Python default, because the SQL server must know the value. It is not supported on MySQL or MariaDB.

The easiest restriction to miss is structural. Django forbids mixing DB_* variants with Python-managed variants in the same model and in models related to it, except for DO_NOTHING. Before replacing a CASCADE, map the full foreign-key chain, not just the field you are changing.

A sensible migration strategy

Start with measured hot paths. For reads, enable FETCH_RAISE in a test or on an export queryset to find implicit accesses, then choose between explicit preloading and FETCH_PEERS. Django’s fetch modes documentation does not remove the need to measure SQL and memory.

For deletes, first look for signals attached to child models. If a signal triggers a domain side effect, keep CASCADE or move that effect into an explicit service before adopting DB_CASCADE. Then inspect the generated migration on every database engine supported by your application.

FETCH_PEERS and DB_CASCADE are useful because they make trade-offs more visible. The first decides when to batch reads. The second decides whether Django or the database owns delete side effects. They are not switches to enable everywhere. They are decisions to make at queryset and model level.