Работая с Django я долго думал, что  post.author.name - это магия одного SQL запроса с JOIN, где-то там под капотом. На самом деле, оказалось не все так однозначно. По умолчанию Django ORM ленивая, каждое такое обращение это отдельный SELECT запрос к БД. Условно говоря, если в цикле пройтись по 100 постам, дернув у каждого  .author.name, получится не один а 101 запрос. В этом и кроется основная проблема N+1

JOIN появляется только когда мы сами просим через select_related.

posts (id, title, author_id)
authors (id, name)
for posts in Post.objects.all():  # Пример N + 1 запрос
	print(post.author.name)

for post in Post.objects.select_related('author').all():  # 1 запрос с select_related
	print(post.author.name)

Под капотом select_related('author') выглядит так: 

-- INNER JOIN: посты где есть автор
SELECT posts.title, authors.name
FROM posts
JOIN authors ON posts.author_id = authors.id;

Если автора нет(когда author_id NULL) и мы не хотим терять такие посты, то нужен LEFT JOIN:

SELECT post.title, authors.name
FROM posts
LEFT JOIN authors ON posts.author_id = authors.id;

Разница простая: JOIN оставляет совпавшие строки, LEFT JOIN не теряет левую сторону и ставит пусто туда, где нет пары.