Peguei o changelog do RC1 numa sexta à noite, sem intenção nenhuma de escrever sobre isso — só queria ver se o MAILERS novo ia me obrigar a reescrever meia dúzia de settings. Três horas depois eu tinha um bloco de notas cheio de "espera, isso resolve aquele troço que a gente contorna há dois anos" e decidi que valia a pena compartilhar. O Django 6.1 chega com data prevista para 5 de agosto, e apesar do changelog completo ter uma quantidade generosa de mudanças cosméticas no admin, o coração da release está em dois lugares muito específicos: como o ORM busca dados que você "esqueceu" de carregar, e como ele lida com deleção em cascata. Se você mantém qualquer sistema com relacionamentos complexos, isso aqui vai te afetar mais do que qualquer feature de CSP ou template tag nova.
Vou tentar não fazer isso parecer um resumo de release notes traduzido. É basicamente o que eu queria ter lido antes de decidir se valia a pena testar o RC no meu ambiente — sem formalidade, com mais desabafo do que resumo técnico.
O fim do N+1 como a gente conhece (ou pelo menos uma trégua)
Todo mundo que já escreveu Django sério bateu nesse muro: você itera um queryset, acessa um campo relacionado dentro do loop, e de repente o Django Debug Toolbar mostra 400 queries onde deveriam existir duas. A solução de sempre é select_related() ou prefetch_related(), só que isso exige que você saiba, com antecedência, exatamente quais campos vai tocar — e em código real, com múltiplas camadas de serializers e templates, isso quase nunca é verdade.
O 6.1 introduz os fetch modes, e essa é, sem exagero, a mudança de comportamento de query mais relevante desde que o select_related ganhou suporte a caminhos aninhados. Agora você define, por queryset, como o Django deve reagir quando um campo não carregado é acessado:
from django.db import models
books = Book.objects.fetch_mode(models.FETCH_PEERS)
for book in books:
print(book.author.name)
Repara que eu não coloquei select_related("author") em lugar nenhum. Com FETCH_PEERS, quando o primeiro book.author é acessado, o Django não busca só aquele autor — ele busca de uma vez os autores de todos os livros que vieram daquele mesmo queryset. Na prática funciona como um prefetch_related() que você não precisou declarar antecipadamente. O loop inteiro, que antes gerava uma query por livro, cai para duas: uma pros livros, uma pros autores.
Existem três modos, e vale entender os três porque cada um resolve um problema diferente:
FETCH_ONEé o comportamento padrão, o que o Django sempre fez: busca o campo faltante só para a instância atual, uma query de cada vez.FETCH_PEERSé o que descrevi acima — o resgate silencioso do N+1 sem você precisar mapear os relacionamentos na mão.RAISEé o mais interessante para quem, como eu, já foi acordado às 3h por uma query lenta em produção. Esse modo levantaFieldFetchBlockedsempre que alguém tenta acessar um campo não carregado. Serve para travar trechos de código sensíveis a performance e forçar quem está mexendo ali a ser explícito sobre o que vai buscar.
Eu ia usar RAISE em qualquer view de listagem paginada que atenda usuário final. Não é falta de confiança em quem escreveu o código — é que um code review dificilmente pega uma query N+1 que só aparece quando a paginação tem 200 itens em vez de 20 no ambiente de teste. Deixar o próprio framework travar isso em tempo de execução é bem mais honesto que uma regra de linter.
Tem um efeito colateral que passou meio despercebido no changelog e que merece atenção: chamar select_related() sem nenhum argumento — aquele hábito preguiçoso de jogar tudo pro Django resolver — está oficialmente em depreciação. A recomendação agora é ou listar os campos explicitamente, ou trocar por fetch_mode(FETCH_PEERS). Se você tem algum select_related() solto por aí sem argumentos, vale caçar isso antes que vire warning em produção.
Deleção sai do Python e desce pro banco
A segunda mudança grande é menos chamativa, mas para quem trabalha com tabelas grandes é praticamente um alívio. O ForeignKey.on_delete ganhou três novas opções: DB_CASCADE, DB_SET_NULL e DB_SET_DEFAULT.
Até aqui, mesmo o CASCADE do Django era resolvido em Python: o ORM precisava carregar os objetos relacionados na memória antes de apagá-los, para disparar os sinais pre_delete e post_delete corretamente. Isso é necessário quando você depende desses sinais para, sei lá, invalidar cache ou notificar um serviço externo. Mas quando você só quer que a exclusão em cascata aconteça e não tem nenhuma lógica pendurada nesses sinais, aquele carregamento inteiro é desperdício puro — memória, tempo, round-trips ao banco que não deveriam existir.
As novas opções resolvem isso delegando a exclusão para a cláusula ON DELETE nativa do SQL:
class Comentario(models.Model):
post = models.ForeignKey(
Post,
on_delete=models.DB_CASCADE,
)
O banco cuida de tudo. Nenhuma linha é carregada em Python antes de sumir. É rápido, é do jeito que bancos relacionais foram desenhados para funcionar desde sempre. O detalhe que você precisa guardar: como o DB_CASCADE não passa pelo Python, ele não dispara pre_delete nem post_delete. Se seu código depende desses sinais para invalidar cache, notificar filas ou qualquer coisa do tipo, DB_CASCADE não é opção — continue com o CASCADE de sempre. Isso não é bug, é a troca que você está fazendo: velocidade por controle.
Onde eu vejo valor imediato é em tabelas de log, auditoria, eventos — qualquer relacionamento onde a exclusão em cascata é pura higiene de dados e ninguém está ouvindo sinal nenhum. Trocar essas por DB_CASCADE deve tirar uma quantidade boa de carga de operações de limpeza em massa.
O que muda debaixo do capô nas queries (e pode quebrar coisa sua)
Fora as duas mudanças grandes, tem uma lista de ajustes no comportamento de queries que parecem pequenos isoladamente, mas somados formam um pacote de "leia antes de fazer merge da atualização":
Aliases de SELECT agora são sempre citados (quoted). Isso vale para os aliases gerados por annotate() e para os aliases de tabela e JOIN. A intenção é evitar colisão com caracteres especiais, mas o efeito colateral é que aliases citados são case-sensitive no SQL. Se você tem algum RawSQL referenciando um alias com capitalização diferente da que o Django gera, isso vai quebrar silenciosamente até você notar. Vale grepar o projeto atrás de RawSQL antes de subir para 6.1.
first() e last() mudaram o comportamento com ordenação limpa. Se você chama order_by() sem argumentos para remover explicitamente qualquer ordenação de um queryset, first() e last() não vão mais cair de volta pra ordenar pela chave primária. Se o seu código depende implicitamente dessa ordenação de fallback, o resultado agora é não-determinístico — e isso é o tipo de bug que só aparece em produção, com volume de dados real.
iexact=None em transforms de JSONField muda de significado. Antes, isso era interpretado como isnull. Agora passa a casar de fato com o null JSON, igual o exact=None já fazia. Se você tem lookup com esse padrão em campo JSON, o resultado da query muda — vale revisar.
JSONNull chega para resolver uma ambiguidade antiga. Usar None para representar o null JSON no nível mais alto de um JSONField está sendo depreciado a favor da nova expressão django.db.models.JSONNull. Isso importa porque None sempre foi ambíguo — podia significar "sem valor no Python" ou "null literal no JSON armazenado" — e agora existe uma forma explícita de dizer qual dos dois você quer.
values_list(flat=True) sem nome de campo também está com os dias contados. Passa a exigir que você declare o campo: values_list("pk", flat=True). Faz sentido — depender da ordem implícita dos campos do model nunca foi uma boa prática, só que era uma prática comum.
in_bulk() agora encadeia depois de values() e values_list(). Pequeno, mas resolve uma limitação chata pra quem constrói querysets compostos.
Chaves de cache mudam para fragmentos com vary_on. Se você usa cache_page() com vary headers, ou a tag {% cache %} com argumentos extras, as chaves geradas por make_template_fragment_key() mudam. Na prática: a primeira requisição depois do upgrade para qualquer página ou fragmento cacheado que varie por argumento vai ser um cache miss. Não é um bug, é esperado — mas se seu monitoramento vai surtar com um pico de miss rate logo após o deploy, vale já deixar isso documentado como comportamento esperado antes que alguém entre em pânico.
E o que fica pra trás
Vale registrar os cortes de suporte, porque eles chegam sem cerimônia e travam upgrade de gente que não estava prestando atenção: PostgreSQL 15 vira o mínimo (adeus PG 14), MySQL sobe para 8.4, MariaDB para 10.11, e o SQLite mínimo suportado sobe de 3.31.0 para 3.37.0. Se seu ambiente de CI ainda roda uma imagem antiga de banco só porque "sempre funcionou", essa é a hora de atualizar isso antes que o Django simplesmente pare de instalar.
O que eu levo desse RC
Fetch modes e DB_CASCADE são o tipo de feature que não aparece em nenhum post de "10 novidades legais do Django", porque não têm brilho — são sobre fazer o ORM se comportar do jeito que a gente sempre torceu silenciosamente para ele se comportar. FETCH_PEERS resolve um problema que a comunidade Django vem remendando com pacotes de terceiros e disciplina de code review há anos. DB_CASCADE reconhece que nem toda exclusão precisa da cerimônia completa de sinais do Django, e que às vezes o banco simplesmente faz esse trabalho melhor.
Se você mantém um sistema em produção com Django, minha recomendação prática: suba um ambiente de staging com o RC1 agora, rode a suíte de testes, e principalmente vá atrás de select_related() sem argumentos e de qualquer RawSQL que referencie aliases manualmente. São os dois pontos com maior chance de te morder silenciosamente. O resto — cache keys, values_list, JSONNull — dá para resolver com calma até agosto.
Testem o RC, reportem bug. É a única forma da versão final sair redonda.
