← Curso IA na prática para Analistas de Dados: do pedido vago ao número que se sustenta 20 aulas
Consultar e transformar com conferência

Caso real: a retenção que contava o mesmo usuário duas vezes

Estudo de caso 9 min de leitura

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.

Ao final desta aula você consegue
  • 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.

A conferência que abriu o casosql
-- 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 existem
A coorte tinha mais gente do que a tabela de usuários. Nesse ponto, o número de 71% já estava morto — faltava saber por quê.

A consulta original

Onde a duplicação entravasql
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;
Um usuário que trocou de plano tem duas linhas em 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.

A correção rápida (e ainda errada)
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.

Escolher qual plano interessa
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ários

Uma 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

AntesDepois
Coorte de janeiro5.8474.210
Retidos no mês 14.1512.316
Retenção M171,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.

A conferência que virou parte do relatóriosql
-- 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  ✓
Duas linhas de SQL que teriam economizado duas semanas de comemoração e uma reunião difícil.
ArmadilhaNúmero bom não recebe auditoria

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.

Checagem antes de seguir
  • A contagem da coorte bate com a tabela de origem.
  • Nenhum DISTINCT está 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.
Em uma frase
  • 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 Google

Ainda não há comentários nesta aula.