Caso real: a retenção que contava o mesmo usuário duas vezes
Uma curva de retenção boa demais, investigada até a causa — com o SQL de cada etapa e a conferência que fechou o caso.
A curva de retenção do produto mostrava 71% no mês 1. Bom demais para a categoria, e ninguém tinha feito nada para merecer. O time comemorou por duas semanas.
- Aplicar contagem de controle para localizar duplicação.
- Reconhecer o padrão de duplicidade por junção com tabela de eventos.
- Escrever a conferência que trava a correção.
O sinal de alerta
O primeiro sinal não veio dos dados: veio de alguém do comercial dizendo "isso não parece com o que eu vejo nos clientes". Ceticismo de quem conhece o negócio é um instrumento de validação, e costuma ser o mais barato disponível.
O segundo sinal veio de uma conferência de dez segundos.
-- Quantos usuários entraram na coorte de janeiro?
SELECT COUNT(DISTINCT usuario_id) FROM usuarios
WHERE criado_em >= '2026-01-01' AND criado_em < '2026-02-01';
-- 4.210
-- Quantos a consulta de retenção diz que entraram?
SELECT COUNT(*) FROM (/* a CTE 'coorte' do relatório */) c;
-- 5.847 ← 1.637 a mais que existemA consulta original
WITH coorte AS (
SELECT u.id AS usuario_id, u.criado_em, p.plano
FROM usuarios u
JOIN assinaturas p ON p.usuario_id = u.id -- ← aqui
WHERE u.criado_em >= '2026-01-01'
AND u.criado_em < '2026-02-01'
),
ativos_m1 AS (
SELECT DISTINCT e.usuario_id
FROM eventos e
WHERE e.ocorrido_em >= '2026-02-01'
AND e.ocorrido_em < '2026-03-01'
)
SELECT
COUNT(*) AS coorte_total,
COUNT(a.usuario_id) AS retidos,
COUNT(a.usuario_id) * 100.0 / COUNT(*) AS retencao_pct
FROM coorte c
LEFT JOIN ativos_m1 a ON a.usuario_id = c.usuario_id;assinaturas. A junção da CTE coorte multiplicou esses usuários — e quem troca de plano é justamente quem está engajado.O detalhe que torna o erro perverso: a duplicação não é aleatória. Ela atinge preferencialmente os usuários que trocaram de plano — pessoas mais engajadas, com mais chance de estarem ativas no mês 1. O numerador inflou mais que o denominador, e a retenção subiu.
Um erro que inflasse os dois igualmente teria deixado a taxa intacta e passado despercebido para sempre. Esse pelo menos apareceu.
SELECT DISTINCT u.id AS usuario_id, u.criado_em, p.plano FROM usuarios u JOIN assinaturas p ON p.usuario_id = u.id ...
O DISTINCT não resolve: o usuário tem plano "básico" e "pro", então as duas linhas são distintas. A duplicação continua.
SELECT u.id AS usuario_id, u.criado_em,
(SELECT a.plano FROM assinaturas a
WHERE a.usuario_id = u.id
ORDER BY a.criado_em ASC LIMIT 1) AS plano_inicial
FROM usuarios u
WHERE u.criado_em >= '2026-01-01'
AND u.criado_em < '2026-02-01';
-- 4.210 linhas ✓ bate com a tabela de usuáriosUma linha por usuário, com o plano de entrada — que é o que uma análise de coorte quer saber.
A diferença: DISTINCT é o remédio errado para quase toda duplicação: ele mascara o sintoma quando as linhas são idênticas e falha silenciosamente quando não são. A pergunta certa é "qual das N linhas eu quero?" — e responder isso costuma revelar uma decisão de análise que ninguém tinha tomado.
O número verdadeiro
| Antes | Depois | |
|---|---|---|
| Coorte de janeiro | 5.847 | 4.210 |
| Retidos no mês 1 | 4.151 | 2.316 |
| Retenção M1 | 71,0% | 55,0% |
55% continua sendo um número razoável para a categoria. A diferença é que agora ele é verdadeiro — e o time parou de tomar decisão sobre um patamar que não existia.
-- Roda junto com o relatório, toda vez.
-- Se der diferente de 0, o relatório não é publicado.
SELECT
(SELECT COUNT(*) FROM coorte) -
(SELECT COUNT(DISTINCT usuario_id) FROM coorte) AS duplicatas_na_coorte,
(SELECT COUNT(*) FROM coorte) -
(SELECT COUNT(*) FROM usuarios
WHERE criado_em >= '2026-01-01' AND criado_em < '2026-02-01')
AS divergencia_vs_usuarios;
-- 0 | 0 ✓Quando um resultado é ruim, todo mundo questiona a metodologia. Quando é bom, ninguém pergunta nada. Esse desequilíbrio faz erros que inflam sobreviverem muito mais tempo que erros que desinflam.
A regra que compensa isso: um número surpreendentemente bom recebe a mesma auditoria que um surpreendentemente ruim — de preferência antes de ser comunicado.
Como perceber: O time está comemorando um indicador que ninguém conferiu.
- A contagem da coorte bate com a tabela de origem.
- Nenhum
DISTINCTestá mascarando duplicação. - Toda junção com tabela de histórico define qual linha usar.
- A conferência roda junto com o relatório, não uma vez só.
- Números bons passaram pela mesma auditoria que números ruins.
DISTINCTé o remédio errado para duplicação — a pergunta certa é qual das N linhas você quer.
Comentários e dúvidas
Inscreva-se grátis para comentar, tirar dúvidas, marcar seu progresso e emitir o certificado ao fim do curso.
Inscrever-se grátis com GoogleAinda não há comentários nesta aula.