Existe uma classe de bug no Salesforce que nunca aparece no seu sandbox e nunca some sozinha em produção. O código roda verde no teste, passa no code review, sobe tranquilo. Semanas depois, um import de reposição entra com 200 linhas e a transação inteira morre com Too many SOQL queries: 101. Ninguém tocou no código. O que mudou foi o volume, e o volume é exatamente o que o seu teste de três registros nunca mediu. Bulkificar é a disciplina que separa o Apex que aguenta a carga real do que só aguenta a demonstração.
Seu Apex quebra com 200 registros porque faz SOQL ou DML dentro do loop, e o trigger recebe os registros em blocos de até 200 de uma vez. Bulkificar é processar a coleção inteira com um número fixo de queries: colete os IDs, faça uma consulta só e trabalhe em memória com um Map, fora do laço.
O que é bulkificação, e por que 200 é o número mágico
Bulkificação é a disciplina de escrever código que trata uma coleção de registros com um número constante de queries e DMLs, não importa se o lote tem 1 ou 200 registros. É o oposto do reflexo natural de quem aprendeu a programar fora do Salesforce, onde você itera sobre uma lista e, para cada item, vai ao banco buscar o que precisa. Nesse mundo, código ineficiente fica lento. No Salesforce, código não bulkificado simplesmente para, com uma exceção que você não consegue capturar.
O número 200 não é arbitrário. Ele é o tamanho do bloco com que a plataforma entrega registros a um trigger. Quando você importa 1.000 linhas, o Salesforce não dispara o trigger uma vez com 1.000 registros na mão. Ele quebra a carga em cinco execuções de 200, e o seu código roda cinco vezes, cada vez com um Trigger.new de no máximo 200. Cada uma dessas execuções é uma transação com o seu próprio orçamento de governor limits: 100 queries SOQL, 150 statements DML, e por aí vai.
A conta que derruba o código é simples. Se você tem uma query dentro do loop e o loop roda 200 vezes, são 200 queries numa transação que só permite 100. A 101ª iteração lança Too many SOQL queries: 101, a transação sofre rollback e a carga inteira daquele bloco não grava nada. No seu sandbox, você testou com três registros. O loop rodou três vezes, gastou três queries, tudo verde. O bug sempre esteve lá. O volume só o tornou visível.
O padrão que resolve quase tudo: coletar, consultar, mapear
A receita da bulkificação tem três passos que você repete em quase toda situação: colete os IDs que precisa, faça uma consulta só para todos eles, e trabalhe em memória com um Map. Veja um caso comum, uma trigger em Contact que copia o segmento de mercado da conta pai para um campo do contato.
Este é o jeito errado, o que estoura no primeiro import grande:
// NAO faca: uma query por iteracao
for (Contact c : Trigger.new) {
Account conta = [SELECT Industry FROM Account WHERE Id = :c.AccountId];
c.Segmento__c = conta.Industry;
// 200 contatos = 200 queries = erro na 101a
}
E este é o mesmo trabalho bulkificado. Colete os IDs numa passada, faça uma query só, indexe o resultado num Map e deixe o loop apenas ler de memória:
// 1. colete os IDs dos pais
Set<Id> contaIds = new Set<Id>();
for (Contact c : Trigger.new) {
if (c.AccountId != null) contaIds.add(c.AccountId);
}
// 2. uma unica query para todos
Map<Id, Account> contas = new Map<Id, Account>(
[SELECT Id, Industry FROM Account WHERE Id IN :contaIds]);
// 3. trabalhe em memoria, zero query no loop
for (Contact c : Trigger.new) {
Account conta = contas.get(c.AccountId);
if (conta != null) c.Segmento__c = conta.Industry;
}
O construtor new Map<Id, Account>(...) aceita direto o resultado da query e indexa cada registro pelo próprio Id, o que torna o get dentro do loop instantâneo e sem custo de banco. O ganho não é estilo: o lote de 200 contatos passa a gastar uma query em vez de 200, e o mesmo código atende com folga um import de 10.000. Esse é o feijão com arroz da boa prática em Apex: nenhuma query e nenhum DML nunca deve morar dentro de um loop.
Quando o pai depende dos filhos: o padrão Map de lista
O exemplo acima é o caso fácil, onde cada filho tem um pai e você só busca um dado dele. O caso que trava a maioria dos devs é o inverso: quando você precisa atualizar o pai a partir de um cálculo sobre os filhos. Foi exatamente esse cenário que quebrou na Vetra Distribuidora, a empresa fictícia que mantenho como org de demonstração do portfólio: um trigger recalculava o desconto médio da oportunidade a partir das linhas de produto, porque a regra tinha faixa por família e não cabia num Roll-Up Summary padrão. Com uma oportunidade de poucas linhas, tudo verde. Ao simular um import de 40 oportunidades com dezenas de linhas cada, o trigger consultava as linhas de cada oportunidade dentro do loop e estourou o limite de queries. A causa era query no loop de novo, só que disfarçada de rollup.
A técnica que resolve é agrupar os filhos por Id do pai num único Map, montado a partir de uma consulta só, antes de qualquer cálculo:
// os IDs das oportunidades afetadas
Set<Id> oppIds = Trigger.newMap.keySet();
// agrupe as linhas por oportunidade, numa query so
Map<Id, List<OpportunityLineItem>> linhasPorOpp =
new Map<Id, List<OpportunityLineItem>>();
for (OpportunityLineItem oli : [
SELECT Id, OpportunityId, Discount
FROM OpportunityLineItem
WHERE OpportunityId IN :oppIds]) {
if (!linhasPorOpp.containsKey(oli.OpportunityId)) {
linhasPorOpp.put(oli.OpportunityId, new List<OpportunityLineItem>());
}
linhasPorOpp.get(oli.OpportunityId).add(oli);
}
Com o Map montado, o cálculo e a gravação também ficam em lote. Você itera sobre os pais, lê a lista de filhos do Map, calcula em memória e acumula numa lista para um único update no fim:
List<Opportunity> paraAtualizar = new List<Opportunity>();
for (Id oppId : oppIds) {
List<OpportunityLineItem> linhas = linhasPorOpp.get(oppId);
if (linhas == null || linhas.isEmpty()) continue;
Decimal soma = 0;
for (OpportunityLineItem oli : linhas) {
soma += (oli.Discount == null ? 0 : oli.Discount);
}
paraAtualizar.add(new Opportunity(
Id = oppId,
Desconto_Medio__c = soma / linhas.size()));
}
update paraAtualizar; // um DML para todas as oportunidades
Repare que existem dois loops aninhados na segunda parte, o de oportunidades e o de linhas, e isso está correto: os dois iteram sobre coleções já carregadas em memória. O que a bulkificação proíbe não é o loop aninhado, é o acesso ao banco dentro dele. Esse padrão de Map<Id, List<Filho>> é a ferramenta que resolve quase todo cenário de pai que depende de filho, e é justamente o que quase ninguém aprende antes de quebrar em produção.
DML no loop: o mesmo erro, do outro lado
Query dentro do loop estoura o limite de 100. O irmão gêmeo é o DML dentro do loop, que estoura o de 150. O padrão perigoso grava registro por registro:
for (Account a : contas) {
a.Status__c = 'Ativo';
update a; // 200 contas = 200 DMLs = erro no 151o
}
A correção é a mesma lógica da query: acumule numa lista e faça um único DML sobre ela, fora do loop. Um update sobre uma lista de 200 registros conta como um statement DML, não 200. Como o mecanismo por trás desses tetos é o mesmo, tratei os governor limits e a razão de eles existirem em detalhe no guia dos limites que mais estouram; aqui o foco é a técnica que os respeita.
A conta que prova o ganho
O valor da bulkificação fica evidente quando você coloca os números lado a lado. A tabela abaixo mostra quantas queries o mesmo trabalho consome nas duas abordagens, conforme o tamanho do lote que o trigger recebe:
| Registros no lote | Query no loop | Bulkificado | Resultado |
|---|---|---|---|
| 3 (seu teste) | 3 queries | 1 query | Ambos passam |
| 100 | 100 queries | 1 query | No limite, arriscado |
| 200 (import real) | 200 queries | 1 query | O ingênuo estoura na 101a |
| 10.000 | 10.000 queries | 1 query | Só o bulkificado sobrevive |
A leitura é direta. O código não bulkificado tem consumo linear: dobrou o volume, dobrou as queries, até bater no teto. O bulkificado tem consumo constante: uma query serve para qualquer tamanho de lote. É por isso que testar com três registros é uma armadilha. Nessa linha, os dois códigos passam, e o bug fica invisível até o dia em que o volume real chega.
Bulkifique, mas cuide da recursão
Um detalhe que morde quem acabou de bulkificar: a mesma transação pode disparar o seu trigger mais de uma vez. Se o seu código atualiza registros que reentram no próprio trigger, ou se um workflow revisita o registro, o loop bulkificado roda de novo e pode refazer trabalho ou empilhar queries entre as reexecuções. A defesa não é espalhar checagens pelo código, é centralizar o controle de contexto num trigger handler organizado, que decide o que roda em cada evento e evita a reentrada indevida. Bulkificação e um handler enxuto resolvem juntos a maioria dos problemas de trigger em produção.
E quando nem a bulkificação basta, porque o volume a processar é grande demais para caber numa transação de 200, o caminho deixa de ser o trigger síncrono e passa a ser o processamento assíncrono. A escolha entre Queueable, Batch e Schedulable é o próximo degrau: o Batch quebra milhões de registros em blocos com os limites renovados, cada bloco já bulkificado por dentro.
Quando você não precisa se preocupar com isso
Honestidade primeiro: se você é admin e nunca escreveu uma linha de Apex, provavelmente nunca vai bater de frente com bulkificação. O Flow, os fluxos padrão e a maioria das automações declarativas já operam de forma bulkificada por baixo dos panos, e o motor cuida disso por você. O risco real aparece quando existe código customizado, quando você chama Apex de dentro do Flow, ou quando várias automações disparam umas às outras na mesma transação.
E não caia no extremo oposto de otimizar o que não precisa. Se a sua trigger, por regra de negócio, nunca vai ver mais que um punhado de registros por vez, montar máquina de bulkificação elaborada é esforço desperdiçado. Mas a regra base é barata e vale sempre: nunca ponha SOQL ou DML dentro de um loop, e escreva o teste que insere 200 registros de uma vez. Fazendo só isso, você elimina a esmagadora maioria dos incidentes de volume, sem precisar de mais nada, nem de mim.
Perguntas frequentes
Por que o Apex quebra justamente com 200 registros?
Porque o trigger recebe os registros em blocos de até 200 por vez, e se há SOQL ou DML dentro do loop, cada registro consome uma query ou um DML do orçamento da transação. Com 101 iterações a query estoura no limite de 100, e o DML no de 150. O sandbox testa com poucos registros e nunca chega perto do teto, então o bug só aparece na carga real.
O que significa bulkificar um código Apex?
Bulkificar é escrever o código para processar uma coleção inteira com um número fixo de queries e DMLs, independente de o lote ter 1 ou 200 registros. A receita é coletar os IDs primeiro, fazer uma única consulta para todos, guardar o resultado num Map indexado por Id e depois iterar lendo de memória, sem tocar o banco dentro do loop.
Como bulkificar quando preciso atualizar registros pai a partir dos filhos?
Você agrupa os filhos por Id do pai num Map de Id para lista, numa passada só sobre uma query única. Depois itera sobre os pais lendo a lista de filhos correspondente do Map, calcula o novo valor em memória e faz um único update sobre a lista de pais. Nenhuma query ou DML mora dentro dos loops.
Bulkificação resolve o limite de registros ou ainda preciso de Batch Apex?
São problemas diferentes. Bulkificação resolve queries e DMLs dentro de uma transação, que trata até 200 registros por vez no trigger. Quando você precisa percorrer milhões de registros de uma vez, aí sim é Batch Apex, que quebra o conjunto em blocos com os limites renovados. Bulkifique sempre; use Batch quando o volume não cabe numa transação.
