Программа построена на FastAPI, но в вакансиях на Python не меньше половины проектов написаны на Django. Переучиваться не придётся: HTTP, SQL, транзакции и тесты те же. Отличается то, сколько Django делает за вас, и на этом же ловят на собеседовании: почему страница из двадцати строк делает двести запросов в базу.
Ниже — как устроен Django-проект, как работает его ORM и где у неё ловушка N+1, и как на Django пишут API через Django REST Framework.
Один и тот же цикл по шести книгам: без подсказки 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(...) заново, это новый объект и новый запрос.
N+1: select_related и prefetch_relatedспросят на собеседовании
Связанные объекты 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.
Что почитать дальше
- SQLAlchemy и Alembic во FastAPI — то же самое в стеке программы: сессии, транзакции, миграции.
- Pydantic и валидация — с чем сравнивать сериализаторы DRF.
- Тестирование FastAPI — как проверять число запросов и работу с базой в тестах.