← назад к разделу

Программа построена на FastAPI, но в вакансиях на Python не меньше половины проектов написаны на Django. Переучиваться не придётся: HTTP, SQL, транзакции и тесты те же. Отличается то, сколько Django делает за вас, и на этом же ловят на собеседовании: почему страница из двадцати строк делает двести запросов в базу.

Ниже — как устроен Django-проект, как работает его ORM и где у неё ловушка N+1, и как на Django пишут API через Django REST Framework.

book.author в цикле SELECT * FROM book SELECT * FROM author WHERE id = 1 SELECT * FROM author WHERE id = 1 SELECT * FROM author WHERE id = 2 SELECT * FROM author WHERE id = 2 SELECT * FROM author WHERE id = 3 SELECT * FROM author WHERE id = 3 select_related("author") SELECT book.*, author.*FROM book JOIN author 7 запросов против одного

Один и тот же цикл по шести книгам: без подсказки ORM ходит за каждым автором отдельно, с select_related забирает всё одним JOIN.

Чем Django отличается от FastAPIспросят на собеседовании

FastAPI даёт роутинг, валидацию и внедрение зависимостей, а базу, миграции и авторизацию вы собираете сами из SQLAlchemy, Alembic и библиотек. Django поставляется целиком: ORM, миграции, админка, пользователи и права, формы, шаблоны. Это и плюс, и минус: быстро стартовать, но жить приходится по правилам фреймворка.

Проект делится на приложения — пакеты по предметным областям вроде orders или catalog. В каждом models.py с таблицами, views.py с обработчиками, urls.py с маршрутами и admin.py для админки. Схему называют MVT: модель, представление (обработчик запроса) и шаблон. В API-проектах шаблонов почти нет, их место занимают сериализаторы DRF.

Ещё одно отличие — синхронность. Django исторически синхронный и работает через WSGI. Асинхронные представления и асинхронные методы ORM вроде aget и acount в нём есть, но большинство проектов и библиотек экосистемы синхронные, а нагрузку держат числом процессов и потоков сервера.

QuerySet ленивыйспросят на собеседовании

В Django запрос к базе описывают цепочкой: Book.objects.filter(title__startswith="В").exclude(author__name="Пушкин"). Пока вы строите цепочку, в базу ничего не уходит — получается объект QuerySet с описанием запроса. Запрос выполняется, когда результат действительно нужен: при обходе в цикле, list(), len(), срезе с шагом или проверке if queryset.

Отсюда два следствия. Цепочку можно строить по частям в разных функциях — это дёшево. А результат вычисленного QuerySet кешируется в самом объекте: второй list(qs) по тому же объекту в базу не пойдёт. Но если каждый раз писать Book.objects.filter(...) заново, это новый объект и новый запрос.

Связанные объекты ORM загружает лениво. Выбрали шесть книг, в цикле обратились к book.author.name — для каждой книги ушёл отдельный запрос за автором. Один запрос за списком и N за связями — это и есть N+1. На странице из двадцати строк с тремя связями получается шестьдесят лишних запросов.

Лечат двумя методами. select_related("author") — для связей «к одному» (ForeignKey, OneToOneField): Django делает JOIN и достаёт всё одним запросом. prefetch_related("books") — для связей «ко многим» (обратный ForeignKey, ManyToManyField): JOIN тут размножил бы строки, поэтому Django делает второй запрос с WHERE author_id IN (...) и раскладывает результат по объектам в памяти.

Так не надо:

for book in Book.objects.all():
    print(book.title, book.author.name)

Так надо:

for book in Book.objects.select_related("author"):
    print(book.title, book.author.name)

for author in Author.objects.prefetch_related("books"):
    print(author.name, [book.title for book in author.books.all()])

Те же три стратегии на голом SQL, с подсчётом запросов:

live example

import sqlite3

db = sqlite3.connect(":memory:")
queries = []
db.set_trace_callback(queries.append)
db.executescript("""
    CREATE TABLE author (id INTEGER PRIMARY KEY, name TEXT);
    CREATE TABLE book (id INTEGER PRIMARY KEY, title TEXT, author_id INTEGER);
    INSERT INTO author VALUES (1, 'Толстой'), (2, 'Чехов'), (3, 'Пушкин');
    INSERT INTO book VALUES (1, 'Война и мир', 1), (2, 'Анна Каренина', 1),
        (3, 'Чайка', 2), (4, 'Вишнёвый сад', 2), (5, 'Онегин', 3), (6, 'Дубровский', 3);
""")


def count(label, load):
    queries.clear()
    load()
    print(f"{label}: запросов {len(queries)}")


def n_plus_one():
    for book_id, author_id in db.execute("SELECT id, author_id FROM book").fetchall():
        db.execute("SELECT name FROM author WHERE id = ?", (author_id,)).fetchone()


def select_related():
    db.execute("SELECT book.title, author.name FROM book JOIN author ON author.id = book.author_id").fetchall()


def prefetch_related():
    authors = db.execute("SELECT id, name FROM author").fetchall()
    ids = [author_id for author_id, _ in authors]
    marks = ",".join("?" * len(ids))
    db.execute(f"SELECT title, author_id FROM book WHERE author_id IN ({marks})", ids).fetchall()


count("книга за книгой", n_plus_one)
count("как select_related", select_related)
count("как prefetch_related", prefetch_related)
Run

Running examples is part of paid access. There the same code runs inside the article: editor, run and check next to the paragraph. Three free days →

Найти N+1 помогает django-debug-toolbar в разработке и assertNumQueries в тестах: тест падает, если число запросов выросло.

Миграции

Модели описывают таблицы в коде, а схему базы меняют миграции. python manage.py makemigrations сравнивает модели с прошлым состоянием и пишет файл миграции, python manage.py migrate применяет его к базе. Это тот же цикл, что у Alembic, только автогенерация встроена и включена всегда. Файлы миграций коммитят вместе с кодом и читают на ревью: переименование поля Django может принять за удаление старого и создание нового, а это потеря данных.

Django REST Frameworkспросят на собеседовании

Для API на Django берут DRF. Сериализатор отвечает за то же, за что Pydantic-модель во FastAPI: проверяет входные данные и превращает объекты в JSON. ModelSerializer строит поля по модели сам. ModelViewSet даёт сразу список, создание, чтение, обновление и удаление, а роутер раздаёт им адреса:

from rest_framework import routers, serializers, viewsets


class BookSerializer(serializers.ModelSerializer):
    author = serializers.CharField(source="author.name", read_only=True)

    class Meta:
        model = Book
        fields = ["id", "title", "author"]


class BookViewSet(viewsets.ModelViewSet):
    queryset = Book.objects.select_related("author")
    serializer_class = BookSerializer


router = routers.DefaultRouter()
router.register("books", BookViewSet)

Обратите внимание на select_related в queryset: сериализатор обращается к author.name у каждой книги, и без подсказки список из ста книг сделал бы сто один запрос. В DRF это самое частое место для N+1.

Коротко

  • Django — фреймворк «всё включено»: ORM, миграции, админка, пользователи; проект делится на приложения, схема MVT.
  • QuerySet ленивый: запрос уходит при обходе, list(), len(); вычисленный QuerySet кеширует результат.
  • N+1 — запрос за списком плюс по запросу на каждую связь в цикле.
  • select_related — JOIN для связей «к одному», prefetch_related — второй запрос с IN для связей «ко многим».
  • makemigrations пишет миграцию по моделям, migrate применяет; файлы миграций читают на ревью.
  • DRF: ModelSerializer вместо Pydantic-модели, ModelViewSet и роутер вместо набора эндпоинтов; select_related — прямо в queryset.

Что почитать дальше