Django e PostgreSQL em 2026: índices, busca full-text e questões de entrevista

Guia prático de otimização de Django com PostgreSQL: índices B-tree, parciais e de cobertura, busca full-text com SearchVector e GIN, além de questões de entrevista de 2026.

Otimização de Django com PostgreSQL usando indexação e busca full-text

A otimização de Django com PostgreSQL é o que separa uma aplicação que escala além dos primeiros mil usuários de outra que trava a cada listagem. O PostgreSQL vem com um mecanismo de índices e um subsistema de busca full-text que a maioria dos projetos Django nunca toca, desperdiçando um ganho de desempenho que não custa nada para recuperar. Este guia aborda os padrões de indexação, as ferramentas de busca de django.contrib.postgres e as questões de entrevista sobre banco de dados que separam pessoas desenvolvedoras Django plenas das sêniores em 2026.

Meça antes de indexar

Todo índice acelera as leituras, mas torna as escritas mais lentas e consome disco. Rode EXPLAIN (ANALYZE, BUFFERS) na consulta real, adicione o índice e compare o plano. Um índice que o planejador de consultas nunca escolhe é puro custo extra em cada INSERT e UPDATE.

Fundamentos de indexação no PostgreSQL com Django

Por padrão, o PostgreSQL cria um índice B-tree apenas nas chaves primárias e nas colunas com unique=True. Toda outra coluna usada em filtro ou ordenação executa uma varredura sequencial até que exista um índice. O Django expõe os índices por meio de Meta.indexes, opção preferível ao antigo db_index=True porque concentra em um só lugar os índices compostos, parciais e de cobertura.

A ordem das colunas dentro de um índice composto não é detalhe estético. Uma B-tree só consegue usar o índice em uma consulta quando o filtro cobre um prefixo inicial das colunas indexadas, regra detalhada em Use The Index, Luke. Um índice em (status, created_at) atende WHERE status = ? ORDER BY created_at, mas o mesmo índice não acelera uma consulta que filtra apenas por created_at.

python
# models.py
from django.db import models

class Order(models.Model):
    customer_email = models.EmailField()
    status = models.CharField(max_length=20)
    total = models.DecimalField(max_digits=10, decimal_places=2)
    created_at = models.DateTimeField(auto_now_add=True)

    class Meta:
        indexes = [
            # B-tree de coluna única para buscas exatas e por faixa em status
            models.Index(fields=["status"]),
            # Índice composto: atende WHERE status = ? ORDER BY created_at DESC
            # A coluna inicial (status) precisa aparecer no filtro para ser usada
            models.Index(fields=["status", "-created_at"]),
        ]

O índice composto transforma uma consulta de dashboard como "últimos pedidos pendentes" em uma única varredura de índice, em vez de filtrar e ordenar a tabela inteira.

Índices parciais e de cobertura para consultas específicas

Um índice parcial cobre apenas as linhas que satisfazem uma condição, então permanece pequeno e cabe na memória. Quando a maioria das linhas compartilha um mesmo valor e as consultas só tocam a minoria, um índice parcial fica muito mais barato do que um completo. Um índice de cobertura vai além: ao acrescentar colunas fora da chave com include, o PostgreSQL responde à consulta direto do índice sem tocar o heap da tabela, padrão de acesso chamado de index-only scan.

python
# models.py
from django.db import models
from django.db.models import Q

class Order(models.Model):
    # campos definidos acima

    class Meta:
        indexes = [
            # Índice parcial: indexa apenas linhas pendentes, ignorando o histórico concluído
            models.Index(
                fields=["created_at"],
                name="pending_orders_idx",
                condition=Q(status="pending"),
            ),
            # Índice de cobertura: total é lido direto do índice (index-only scan)
            models.Index(
                fields=["status"],
                include=["total"],
                name="status_total_covering_idx",
            ),
        ]

Em uma tabela de pedidos onde 2% das linhas estão pendentes, o índice parcial pode ser cinquenta vezes menor do que um índice completo na mesma coluna, e o planejador o mantém aquecido no buffer cache. A cláusula include está documentada em index-only scans do PostgreSQL e é o ganho mais ignorado em aplicações Django que agregam uma coluna numérica atrás de um filtro por status.

Busca full-text no Django com PostgreSQL

Recorrer a title__icontains=query parece prático, mas força uma varredura sequencial e não consegue ordenar os resultados por relevância. A busca full-text do Django encapsula os tipos nativos tsvector e tsquery do PostgreSQL por meio de SearchVector, SearchQuery e SearchRank. Um vetor de busca normaliza o texto em lexemas, remove as stop words e reduz as palavras aos seus radicais, de modo que uma busca por "running" encontra "run" e "ran".

python
# views.py
from django.contrib.postgres.search import (
    SearchVector, SearchQuery, SearchRank,
)
from .models import Article

def search_articles(query_text):
    query = SearchQuery(query_text, config="english")
    # O peso A classifica correspondências no título acima das do corpo (peso B)
    vector = (
        SearchVector("title", weight="A")
        + SearchVector("body", weight="B")
    )
    return (
        Article.objects.annotate(rank=SearchRank(vector, query))
        .filter(rank__gte=0.1)
        .order_by("-rank")
    )

O parâmetro weight distribui faixas de prioridade de A (mais alta) até D. Uma correspondência no título fica acima de uma no corpo mesmo quando ambos contêm o termo buscado, o que coincide com o comportamento que os usuários esperam de uma busca. A API completa e o modelo de pesos em quatro níveis estão descritos na referência de busca full-text do Django.

Índices GIN e SearchVectorField para buscas rápidas

A consulta acima recalcula o vetor de busca para cada linha a cada requisição, o que funciona bem para alguns milhares de linhas e é inaceitável para um milhão. O padrão de produção armazena o vetor em um SearchVectorField e o indexa com um índice GIN, o tipo de índice que o PostgreSQL usa para valores compostos como documentos full-text e JSONB.

python
# models.py
from django.contrib.postgres.search import SearchVectorField
from django.contrib.postgres.indexes import GinIndex
from django.db import models

class Article(models.Model):
    title = models.CharField(max_length=255)
    body = models.TextField()
    # Documento de busca armazenado e pré-computado
    search_vector = SearchVectorField(null=True)

    class Meta:
        indexes = [
            # O GIN transforma buscas full-text em varreduras de índice logarítmicas
            GinIndex(fields=["search_vector"]),
        ]

O campo armazenado precisa se manter sincronizado com title e body. Um sinal post_save que grava o vetor com .update() evita disparar o sinal de forma recursiva, já que QuerySet.update() não emite sinais de save.

python
# signals.py
from django.contrib.postgres.search import SearchVector
from django.db.models.signals import post_save
from django.dispatch import receiver
from .models import Article

@receiver(post_save, sender=Article)
def sync_search_vector(sender, instance, **kwargs):
    Article.objects.filter(pk=instance.pk).update(
        search_vector=SearchVector("title", weight="A")
        + SearchVector("body", weight="B"),
    )

Para times em Django 5.0 ou mais recente, um GeneratedField calculado no banco ou um trigger do PostgreSQL elimina por completo o UPDATE extra ao manter a coluna dentro do próprio banco. Seja qual for o mecanismo que a mantém preenchida, a consulta passa a filtrar o campo indexado diretamente, e o índice GIN transforma o que era uma varredura completa de tabela em uma busca logarítmica.

Índices GIN são caros para escrever

Índices GIN aceleram as leituras a um custo real de escrita, pois cada inserção mexe em muitas entradas do índice. Em tabelas com muitas escritas, defina o parâmetro de armazenamento fastupdate e monitore o tamanho da lista pendente, ou as escritas full-text vão ficar travadas atrás da manutenção do índice.

Escolhendo o tipo de índice certo no PostgreSQL

A B-tree é o padrão correto, mas o PostgreSQL oferece vários tipos de índice por meio de django.contrib.postgres.indexes, e escolher o errado desperdiça disco sem melhorar uma única consulta. A escolha segue o formato dos dados e o operador que a consulta usa, conforme catalogado na documentação de tipos de índice do PostgreSQL.

| Tipo de índice | Melhor para | Classe do Django | |------------|----------|--------------| | B-tree | Igualdade e faixa em colunas escalares | models.Index | | GIN | Busca full-text, JSONB, contenção em arrays | GinIndex | | BRIN | Tabelas enormes append-only ordenadas por uma coluna | BrinIndex | | Hash | Buscas apenas por igualdade em colunas grandes | HashIndex |

O BRIN merece atenção especial em dados de série temporal. Em uma tabela com dezenas de milhões de linhas inseridas em ordem de timestamp, um BrinIndex em created_at ocupa alguns kilobytes onde uma B-tree precisaria de centenas de megabytes, porque o BRIN guarda apenas os valores mínimo e máximo por faixa de blocos. O custo é que o BRIN só ajuda quando a ordem física das linhas acompanha a coluna indexada, o que ocorre naturalmente em logs append-only e tabelas de eventos.

python
# models.py
from django.contrib.postgres.indexes import BrinIndex
from django.db import models

class Event(models.Model):
    payload = models.JSONField()
    created_at = models.DateTimeField(auto_now_add=True)

    class Meta:
        indexes = [
            # Índice minúsculo para varreduras por faixa em uma tabela append-only ordenada por tempo
            BrinIndex(fields=["created_at"]),
        ]

Alinhar o tipo de índice ao padrão de acesso é um tema recorrente em entrevistas sêniores, porque demonstra que a pessoa candidata raciocina sobre a organização do armazenamento em vez de sair adicionando B-trees por reflexo.

Pronto para mandar bem nas entrevistas de Django?

Pratique com nossos simuladores interativos, flashcards e testes tecnicos.

Questões de entrevista sobre banco de dados no Django em 2026

As questões sobre banco de dados dominam as entrevistas Django sêniores porque fluência no ORM não implica entender o que o ORM gera. As perguntas a seguir refletem o que as bancas de contratação realmente investigam em 2026, e combinam naturalmente com o ajuste no nível de consulta abordado no guia de otimização de consultas do Django ORM.

Quando um índice composto em (a, b) deixa de ajudar uma consulta? Quando a consulta não filtra por a. Uma B-tree é ordenada primeiro pela coluna inicial, então um filtro somente por b não consegue usar o índice. Essa é a regra do prefixo mais à esquerda, e quem a explica demonstra entender a estrutura do índice em vez de decorar receitas.

Qual é a diferença entre select_related e prefetch_related? select_related executa um JOIN em SQL e funciona para relações de chave estrangeira e um-para-um em uma única consulta. prefetch_related dispara uma segunda consulta e junta os resultados em Python, o que é necessário para relações muitos-para-muitos e chaves estrangeiras reversas. Usar o errado ou perde a otimização ou dispara uma segunda consulta desnecessária.

Como o icontains difere da busca full-text no nível do banco? icontains compila para ILIKE '%term%', que não consegue usar um índice B-tree padrão e varre todas as linhas. A busca full-text compara com um tsvector pré-computado apoiado em um índice GIN e ordena os resultados por relevância. Essa distinção é um sinal confiável de que a pessoa candidata já passou dos conjuntos de dados de brinquedo.

Por que count() pode ser lento em uma tabela grande e quais são as alternativas? O PostgreSQL não mantém uma contagem de linhas em cache para a tabela, então COUNT(*) varre todas as linhas visíveis sob o MVCC. Para contagens aproximadas, consultar pg_class.reltuples devolve uma estimativa na hora e, para paginação, a paginação por keyset em uma coluna indexada dispensa a contagem por completo. Prática estruturada sobre esses padrões está disponível no módulo de entrevista sobre cache no Django, onde contadores apoiados em cache são um tema recorrente, e em toda a trilha de aprendizado de Django.

O que EXPLAIN ANALYZE revela que EXPLAIN sozinho não mostra? EXPLAIN imprime o custo estimado pelo planejador; EXPLAIN ANALYZE de fato executa a consulta e informa tempos e contagens de linhas reais. Uma grande diferença entre linhas estimadas e reais aponta para estatísticas de tabela desatualizadas, corrigíveis com ANALYZE, e é uma causa raiz comum de o planejador ignorar um índice que obviamente deveria se aplicar.

Comece a praticar!

Teste seus conhecimentos com nossos simuladores de entrevista e testes tecnicos.

Conclusão

  • Defina os índices em Meta.indexes em vez de db_index=True para que índices compostos, parciais e de cobertura fiquem em um único lugar auditável.
  • Ordene as colunas de um índice composto pela regra do prefixo mais à esquerda: a coluna inicial precisa aparecer no filtro da consulta para o índice ser usado.
  • Use índices parciais quando as consultas só tocam uma minoria das linhas, como pedidos pendentes em uma tabela dominada por histórico concluído.
  • Acrescente índices de cobertura com include para habilitar index-only scans e evitar o acesso ao heap em consultas de agregação com muitas leituras.
  • Substitua a busca com icontains por SearchVector, SearchQuery e SearchRank para ganhar ordenação por relevância e stemming de graça.
  • Armazene o documento de busca em um SearchVectorField apoiado por um GinIndex, mantido sincronizado por um sinal, GeneratedField ou trigger de banco, antes de a busca full-text chegar à escala de produção.
  • Sempre valide as decisões de índice com EXPLAIN (ANALYZE, BUFFERS); um índice que o planejador nunca seleciona é um imposto no momento da escrita sem nenhum benefício na leitura.

Tags

#django
#postgresql
#database optimization
#full text search
#python

Compartilhar

Artigos relacionados