SQL para análise de dados · Guias · 30 de outubro de 2026 · 13 min de leitura

Como encontrar e excluir registros duplicados no SQL, com exemplos

Uma pilha de cartões de embarque idênticos, um destacado e as cópias sumindo, ao lado de uma tabela de dados

Para encontrar duplicados no SQL, agrupe pelas colunas que deveriam ser únicas e fique com os grupos que aparecem mais de uma vez: GROUP BY col1, col2 HAVING COUNT(*) > 1. Para ver as linhas duplicadas completas, ou para apagar todas as cópias menos uma, numere as linhas dentro de cada grupo com ROW_NUMBER() OVER (PARTITION BY col1, col2 ORDER BY ...): a linha 1 é a cópia que fica, as linhas 2 em diante são as duplicadas. A parte difícil não é a sintaxe, e sim decidir quais colunas definem “a mesma linha” e qual cópia deve sobreviver.

Todos os exemplos rodam no banco de dados da companhia aérea do curso de SQL da Nomto, com sintaxe do PostgreSQL sobre o DuckDB (o motor da sala de SQL da Nomto), e cada tabela de resultado é a saída real da consulta. A sala de SQL é somente leitura, então os DELETE do fim aparecem só como sintaxe; os SELECT que mostram o que eles apagariam foram executados.

Comece definindo o que é um duplicado

A tabela app_events guarda 1.494.723 eventos dos apps e do site: aberturas do app, buscas, visualizações de ofertas, compras. Cada linha tem o seu próprio event_id, então uma busca por cópias exatas não encontra nada:

SELECT COUNT(*) AS all_rows,
       (SELECT COUNT(*) FROM (SELECT DISTINCT * FROM app_events) d) AS distinct_rows
FROM app_events;
all_rows distinct_rows
1494723 1494723

Isso não quer dizer que a tabela está limpa. Quando o celular perde a conexão, o app reenvia o evento, e o servidor o grava de novo com um event_id novo e um received_at mais tarde. O evento em si, um cliente fazendo uma coisa num momento, é o mesmo. Então a chave de negócio aqui é (client_id, event_time, event_name), e as colunas técnicas event_id e received_at precisam ficar fora da comparação.

Essa é a primeira decisão de qualquer caça a duplicados: liste as colunas que, pelo significado dos dados, têm de ser únicas. Incluir uma coluna de ID na comparação esconde todos os duplicados.

Encontrar valores duplicados numa coluna

O caso mais simples é uma coluna só. Quais nomes de passageiro aparecem em mais de um bilhete?

SELECT passenger_name, COUNT(*) AS tickets
FROM tickets
GROUP BY passenger_name
HAVING COUNT(*) > 1
ORDER BY tickets DESC
LIMIT 3;
passenger_name tickets
ALEKSANDR IVANOV 842
ALEKSANDR KUZNECOV 755
SERGEY IVANOV 634

São 15.389 nomes repetidos. Nada disso é erro de dados: os 366.733 bilhetes têm só 22.220 nomes distintos, e nomes comuns são de muita gente, como um “José da Silva” num cadastro brasileiro. O GROUP BY junta as linhas com o mesmo valor, o HAVING filtra os grupos depois da contagem (veja GROUP BY e HAVING para entender por que WHERE COUNT(*) > 1 não funciona).

Encontrar duplicados em várias colunas

Ponha todas as colunas da chave no GROUP BY. Eventos reenviados em app_events:

SELECT client_id, event_time, event_name, COUNT(*) AS copies
FROM app_events
GROUP BY client_id, event_time, event_name
HAVING COUNT(*) > 1
ORDER BY event_time
LIMIT 5;
client_id event_time event_name copies
0586c43b2bb9 2026-07-01 00:09:01 checkout_start 2
0d823672b3be 2026-07-01 00:11:20 app_open 2
00a725eaf49b 2026-07-01 00:14:34 add_passenger 2
05416737ab4d 2026-07-01 00:20:59 select_fare 2
184daf99054d 2026-07-01 00:34:39 add_passenger 2

Quantos duplicados existem no total?

Envolva a mesma consulta e some. O número de grupos e o número de linhas sobrando são coisas diferentes, e relatórios costumam confundir os dois:

SELECT COUNT(*) AS duplicate_groups,
       SUM(copies) AS rows_in_groups,
       SUM(copies - 1) AS extra_rows
FROM (
  SELECT client_id, event_time, event_name, COUNT(*) AS copies
  FROM app_events
  GROUP BY client_id, event_time, event_name
  HAVING COUNT(*) > 1
) d;
duplicate_groups rows_in_groups extra_rows
10689 21378 10689

Cada evento duplicado aqui tem exatamente duas cópias, então 21.378 linhas estão envolvidas, mas só 10.689 precisam sair. Uma checagem de uma linha dá a mesma resposta sem listar os grupos:

SELECT COUNT(*) AS rows,
       COUNT(DISTINCT (client_id, event_time, event_name)) AS unique_events,
       COUNT(*) - COUNT(DISTINCT (client_id, event_time, event_name)) AS extra
FROM app_events;
rows unique_events extra
1494723 1484034 10689

COUNT(DISTINCT (a, b, c)) com uma tupla entre parênteses funciona no PostgreSQL e no DuckDB; no MySQL se escreve COUNT(DISTINCT a, b, c). O SQL Server só aceita uma expressão em COUNT(DISTINCT ...), então lá use a versão com subconsulta.

Nem toda repetição é duplicado

Defina a chave sem cuidado e você vai “achar” duplicados que são dados reais. O mesmo nome no mesmo voo parece suspeito:

SELECT tf.flight_id, t.passenger_name,
       COUNT(*) AS tickets,
       COUNT(DISTINCT t.passenger_id) AS passengers
FROM ticket_flights tf
JOIN tickets t ON t.ticket_no = tf.ticket_no
GROUP BY tf.flight_id, t.passenger_name
HAVING COUNT(*) > 1
ORDER BY tickets DESC, tf.flight_id
LIMIT 3;
flight_id passenger_name tickets passengers
298 ALEKSANDR IVANOV 5 5
30577 ALEKSANDR IVANOV 5 5
250 SERGEY KUZNECOV 4 4

Há 12.169 pares de voo e nome assim, mas cada bilhete tem um passenger_id diferente: cinco pessoas diferentes chamadas Aleksandr Ivanov no voo 298. Pôr COUNT(DISTINCT id) ao lado de COUNT(*) é um jeito rápido de separar duplicados de verdade de homônimos. Num cadastro brasileiro, o papel do passenger_id costuma ser do CPF.

Mostrar as linhas duplicadas completas

O GROUP BY devolve uma linha por grupo, e você perde as outras colunas. Para ver cada cópia com todas as colunas, conte com uma função de janela, que mantém as linhas (mais em funções de janela no SQL):

SELECT event_id, client_id, event_time, received_at, event_name,
       COUNT(*) OVER (PARTITION BY client_id, event_time, event_name) AS copies
FROM app_events
QUALIFY copies > 1
ORDER BY event_time, client_id, received_at
LIMIT 6;
event_id client_id event_time received_at event_name copies
139 0586c43b2bb9 2026-07-01 00:09:01 2026-07-01 00:09:03 checkout_start 2
140 0586c43b2bb9 2026-07-01 00:09:01 2026-07-01 00:09:04 checkout_start 2
170 0d823672b3be 2026-07-01 00:11:20 2026-07-01 00:11:21 app_open 2
172 0d823672b3be 2026-07-01 00:11:20 2026-07-01 00:11:23 app_open 2
207 00a725eaf49b 2026-07-01 00:14:34 2026-07-01 00:14:34 add_passenger 2
299 00a725eaf49b 2026-07-01 00:14:34 2026-07-01 00:23:54 add_passenger 2

Agora o padrão aparece: mesmo momento, mesma ação, mas a segunda cópia chegou depois. Nos 10.689 pares, o reenvio chegou entre 1 segundo e 10 minutos depois do original, com mediana de 24 segundos.

O QUALIFY filtra por uma função de janela do jeito que o HAVING filtra por uma agregação. DuckDB, Snowflake, BigQuery e Databricks suportam; PostgreSQL, MySQL e SQL Server não, então neles ponha a janela numa subconsulta e filtre por fora: SELECT * FROM (SELECT ..., COUNT(*) OVER (...) AS copies FROM app_events) x WHERE copies > 1.

Uma alternativa mais antiga é juntar a tabela ao resultado do GROUP BY ... HAVING por todas as colunas da chave. Funciona em qualquer banco, mas lê a tabela duas vezes; veja também self join no SQL.

Manter a primeira linha com ROW_NUMBER

Para remover duplicados, numere as cópias dentro de cada grupo e mantenha a número 1. O ORDER BY dentro do OVER decide qual cópia ganha; aqui a primeira recebida, com event_id como desempate para o resultado ser repetível:

WITH ranked AS (
  SELECT event_id, client_id, event_time, received_at, event_name,
         ROW_NUMBER() OVER (
           PARTITION BY client_id, event_time, event_name
           ORDER BY received_at, event_id
         ) AS rn
  FROM app_events
)
SELECT rn, COUNT(*) AS rows
FROM ranked
GROUP BY rn
ORDER BY rn;
rn rows
1 1484034
2 10689

WHERE rn = 1 dá a tabela limpa com 1.484.034 eventos; WHERE rn > 1 dá exatamente as 10.689 linhas a apagar. Sem um ORDER BY determinístico, o banco pode manter uma cópia diferente a cada execução. A diferença entre ROW_NUMBER e RANK aqui é decisiva: veja ROW_NUMBER, RANK e DENSE_RANK.

Qual cópia manter depende dos dados. Para eventos reenviados, a mais antiga é a original. Para uma tabela de clientes carregada várias vezes, você normalmente quer a versão mais recente: ORDER BY updated_at DESC. Para linhas que diferem em completude, você pode ordenar primeiro por “tem e-mail”.

Por que isso importa: métricas infladas

Duplicados raramente quebram uma consulta; eles inflam as contagens em silêncio. Compras por plataforma, brutas e sem duplicados:

WITH ranked AS (
  SELECT platform,
         ROW_NUMBER() OVER (
           PARTITION BY client_id, event_time, event_name
           ORDER BY received_at, event_id
         ) AS rn
  FROM app_events
  WHERE event_name = 'purchase'
)
SELECT platform,
       COUNT(*) AS raw_purchases,
       COUNT(*) FILTER (WHERE rn = 1) AS real_purchases,
       COUNT(*) - COUNT(*) FILTER (WHERE rn = 1) AS duplicates
FROM ranked
GROUP BY platform
ORDER BY platform;
platform raw_purchases real_purchases duplicates
android 21052 20844 208
ios 14262 14161 101
web 16538 16473 65

O Android tem mais reenvios: a contagem bruta de compras fica cerca de 1% acima do real, mais que o dobro do excesso da web, de 0,4%. É pouco, mas o suficiente para mexer numa taxa de conversão ou na comparação de um teste A/B entre plataformas, e no Brasil, onde a maior parte do tráfego mobile é Android, esse é justamente o número que mais pesa.

DISTINCT ON: uma linha por chave no PostgreSQL

PostgreSQL e DuckDB têm um atalho para “a primeira linha de cada grupo”: DISTINCT ON (chave) mantém a primeira linha de cada chave segundo o ORDER BY. O ORDER BY precisa começar pelas mesmas colunas do DISTINCT ON:

SELECT DISTINCT ON (client_id, event_time, event_name)
       event_id, client_id, event_time, event_name, received_at
FROM app_events
WHERE event_name = 'purchase'
ORDER BY client_id, event_time, event_name, received_at, event_id
LIMIT 3;
event_id client_id event_time event_name received_at
143160 000034d98b23 2026-07-05 10:26:38 purchase 2026-07-05 10:26:39
1108749 0001232bce1c 2026-08-02 03:08:12 purchase 2026-08-02 03:08:13
815068 00013b82c7af 2026-07-24 22:56:23 purchase 2026-07-24 22:56:23

Na tabela inteira, ele devolve as mesmas 1.484.034 linhas que rn = 1. O DISTINCT ON é mais curto, mas MySQL, SQL Server e Oracle não o têm, e ele só resolve “manter uma”; o ROW_NUMBER também deixa você manter duas, ou marcar os duplicados sem removê-los.

O SELECT DISTINCT simples só remove duplicados quando todas as colunas selecionadas coincidem, então ajuda com cópias exatas e não faz nada aqui, onde event_id e received_at são diferentes.

Excluir linhas duplicadas

Antes de apagar qualquer coisa, rode o SELECT que lista as vítimas e confira a contagem. Para os eventos reenviados, as linhas a remover são as com rn > 1:

SELECT COUNT(*) AS rows_to_delete
FROM app_events
WHERE event_id IN (
  SELECT event_id
  FROM (
    SELECT event_id,
           ROW_NUMBER() OVER (
             PARTITION BY client_id, event_time, event_name
             ORDER BY received_at, event_id
           ) AS rn
    FROM app_events
  ) r
  WHERE rn > 1
);
rows_to_delete
10689

O número bate com o extra_rows da checagem com GROUP BY, então o delete vai remover exatamente uma cópia de cada par. Como transformar isso num DELETE depende do banco.

PostgreSQL e DuckDB

Quando a tabela tem um ID único, apague pelo ID com a mesma subconsulta:

DELETE FROM app_events
WHERE event_id IN (
  SELECT event_id
  FROM (
    SELECT event_id,
           ROW_NUMBER() OVER (
             PARTITION BY client_id, event_time, event_name
             ORDER BY received_at, event_id
           ) AS rn
    FROM app_events
  ) r
  WHERE rn > 1
);

Se uma tabela do PostgreSQL não tem nenhuma coluna única (cópias exatas), use o endereço físico oculto da linha, ctid, para diferenciar as cópias:

DELETE FROM t a
USING t b
WHERE a.key_col = b.key_col
  AND a.ctid > b.ctid;   -- keeps the copy with the smallest ctid

No DuckDB, recriar a tabela costuma ser mais simples do que apagar dela:

CREATE OR REPLACE TABLE app_events AS
SELECT * FROM app_events
QUALIFY ROW_NUMBER() OVER (
  PARTITION BY client_id, event_time, event_name
  ORDER BY received_at, event_id
) = 1;

SQL Server

O SQL Server deixa apagar através de uma CTE que lê uma única tabela, o que dá a versão mais curta:

WITH ranked AS (
  SELECT ROW_NUMBER() OVER (
           PARTITION BY client_id, event_time, event_name
           ORDER BY received_at, event_id
         ) AS rn
  FROM app_events
)
DELETE FROM ranked
WHERE rn > 1;

MySQL

O MySQL recusa um DELETE cuja subconsulta seleciona diretamente da mesma tabela (erro 1093, “You can’t specify target table for update in FROM clause”). Junte com uma tabela derivada. No MySQL 8.0 em diante, com funções de janela:

DELETE e
FROM app_events e
JOIN (
  SELECT event_id,
         ROW_NUMBER() OVER (
           PARTITION BY client_id, event_time, event_name
           ORDER BY received_at, event_id
         ) AS rn
  FROM app_events
) r ON r.event_id = e.event_id
WHERE r.rn > 1;

No MySQL 5.7, que não tem funções de janela e ainda aparece em muito sistema legado, use um self join: apague uma linha quando outra linha com a mesma chave chegou antes.

DELETE e1
FROM app_events e1
JOIN app_events e2
  ON  e2.client_id  = e1.client_id
  AND e2.event_time = e1.event_time
  AND e2.event_name = e1.event_name
  AND (e2.received_at < e1.received_at
       OR (e2.received_at = e1.received_at AND e2.event_id < e1.event_id));

Seja qual for o banco, rode o delete dentro de uma transação, compare o número de linhas afetadas com o do seu SELECT e só então faça o commit. Em tabelas grandes, apagar em lotes ou recriar a tabela evita bloqueios longos.

Impedir que os duplicados voltem

Apagar conserta o passado; uma constraint conserta o futuro. Um índice único na chave de negócio faz o banco rejeitar a segunda cópia:

CREATE UNIQUE INDEX app_events_dedup
  ON app_events (client_id, event_time, event_name);

Depois, faça a carga tolerar repetições em vez de falhar: INSERT ... ON CONFLICT DO NOTHING no PostgreSQL e no DuckDB, INSERT IGNORE ou INSERT ... ON DUPLICATE KEY UPDATE no MySQL, MERGE no SQL Server. Criar o índice falha enquanto ainda existem duplicados, então limpe antes. Se você não pode mexer na tabela de origem, remova os duplicados numa view ou num modelo de staging com o padrão do ROW_NUMBER e aponte todos os relatórios para ele.

Mais uma armadilha: o GROUP BY põe todos os NULLs num grupo só, então linhas com NULL numa coluna da chave contam como duplicadas umas das outras. Um self join com a.col = b.col nunca casa NULLs e não as encontra. Um índice único trata NULLs como distintos no PostgreSQL por padrão (o PostgreSQL 15 trouxe NULLS NOT DISTINCT para mudar isso). Decida qual comportamento você quer antes de confiar na contagem. Mais em NULL no SQL.

Perguntas frequentes

Como encontrar registros duplicados no SQL?

Agrupe pelas colunas que deveriam ser únicas e filtre com HAVING COUNT(*) > 1: SELECT col1, col2, COUNT(*) FROM t GROUP BY col1, col2 HAVING COUNT(*) > 1. Para ver as linhas completas, use COUNT(*) OVER (PARTITION BY col1, col2) e filtre onde for maior que 1.

Como excluir duplicados e manter uma linha?

Numere as linhas de cada grupo com ROW_NUMBER() OVER (PARTITION BY chave ORDER BY ...) e apague as de número acima de 1. O ORDER BY decide qual cópia sobrevive, então deixe-o determinístico, por exemplo o timestamp mais antigo mais o ID como desempate.

Como remover duplicados sem apagar nada?

Selecione só a primeira cópia: WHERE rn = 1 sobre uma subconsulta com ROW_NUMBER, DISTINCT ON no PostgreSQL e no DuckDB, ou SELECT DISTINCT se as linhas são idênticas em todas as colunas. Coloque isso numa view para que os relatórios leiam a versão limpa enquanto a tabela bruta fica intacta.

Por que o SELECT DISTINCT não remove os meus duplicados?

O DISTINCT compara todas as colunas selecionadas. Se qualquer uma for diferente, como um ID ou um timestamp de carga, as linhas não são idênticas e as duas ficam. Selecione só as colunas da chave, ou use ROW_NUMBER para escolher uma linha completa por chave.

Qual o jeito mais rápido de apagar duplicados numa tabela grande?

Apagar uma pequena parte das linhas por ID costuma ir bem. Quando uma parte grande está duplicada, muitas vezes é mais rápido criar uma tabela nova só com as linhas rn = 1, trocar pela antiga e criar um índice único para o problema não voltar.

Outros idiomas: English, Español, Tiếng Việt, 简体中文