SQL · Guías · 30 de octubre de 2026 · 13 min de lectura

Cómo encontrar y eliminar duplicados en SQL, con ejemplos

Un montón de tarjetas de embarque idénticas, una resaltada y las copias desvaneciéndose, junto a una tabla de datos

Para encontrar duplicados en SQL, agrupa por las columnas que deberían ser únicas y quédate con los grupos que aparecen más de una vez: GROUP BY col1, col2 HAVING COUNT(*) > 1. Para ver las filas duplicadas completas, o para borrar todas las copias menos una, numera las filas dentro de cada grupo con ROW_NUMBER() OVER (PARTITION BY col1, col2 ORDER BY ...): la fila 1 es la copia que conservas y las filas 2 en adelante son los duplicados. Lo difícil no es la sintaxis, sino decidir qué columnas definen «la misma fila» y qué copia debe sobrevivir.

Los ejemplos usan la base de datos de una aerolínea del curso de SQL de Nomto, con sintaxis de PostgreSQL sobre DuckDB (el motor de la sala de SQL de Nomto), y cada tabla de resultados es la salida real. La sala de SQL es de solo lectura, así que los DELETE del final se muestran como sintaxis; los SELECT que anticipan qué borrarían sí se ejecutaron. Si vienes de Excel, esto es el equivalente de «Formato condicional → Valores duplicados» y «Quitar duplicados», pero sobre millones de filas y con control total de qué copia se queda.

Primero, define qué es un duplicado

La tabla app_events guarda 1.494.723 eventos de las apps móviles y de la web: aperturas de la app, búsquedas, vistas de ofertas, compras. Cada fila tiene su propio event_id, así que buscar copias exactas no encuentra 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

Eso no significa que la tabla esté limpia. Cuando un móvil pierde la conexión, la app reenvía el evento y el servidor lo guarda otra vez con un event_id nuevo y un received_at posterior. El evento en sí, un cliente haciendo una cosa en un momento dado, es el mismo. Así que la clave de negocio aquí es (client_id, event_time, event_name), y las columnas técnicas event_id y received_at deben quedar fuera de la comparación.

Es la primera decisión de cualquier caza de duplicados: enumera las columnas que, por el significado de los datos, deben ser únicas. Si incluyes una columna de ID en la comparación, escondes todos los duplicados.

Encontrar valores duplicados en una columna

El caso más simple es una sola columna. ¿Qué nombres de pasajero aparecen en más de un billete?

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

Se repiten 15.389 nombres. Nada de esto es un error de datos: los 366.733 billetes tienen solo 22.220 nombres distintos, y los nombres comunes los comparte mucha gente, igual que pasaría con «José García» o «María Rodríguez». GROUP BY junta las filas con el mismo valor y HAVING filtra los grupos después de contar (en GROUP BY y HAVING verás por qué WHERE COUNT(*) > 1 no funciona).

Encontrar duplicados en varias columnas

Pon todas las columnas de la clave en el GROUP BY. Eventos reenviados en 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

¿Cuántos duplicados hay en total?

Envuelve la misma consulta y suma. El número de grupos y el número de filas sobrantes son cosas distintas, y los informes suelen confundirlas:

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 tiene exactamente dos copias, así que hay 21.378 filas implicadas, pero solo sobran 10.689. Una comprobación de una línea da la misma respuesta sin listar 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)) con una fila entre paréntesis funciona en PostgreSQL y DuckDB; MySQL lo escribe COUNT(DISTINCT a, b, c). SQL Server solo admite una expresión dentro de COUNT(DISTINCT ...), así que ahí usa la versión con subconsulta.

No toda repetición es un duplicado

Define la clave sin cuidado y «encontrarás» duplicados que son datos reales. El mismo nombre en el mismo vuelo parece sospechoso:

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

Hay 12.169 pares de vuelo y nombre así, pero cada billete tiene un passenger_id distinto: cinco personas diferentes llamadas Aleksandr Ivanov en el vuelo 298. Poner COUNT(DISTINCT id) al lado de COUNT(*) es una forma rápida de distinguir duplicados reales de tocayos. En una tabla de clientes de España o Latinoamérica, el equivalente sería comparar por DNI, CURP o RUT en lugar de por nombre.

Ver las filas duplicadas completas

GROUP BY devuelve una línea por grupo y pierdes las demás columnas. Para ver cada copia con todas sus columnas, cuenta con una función de ventana, que conserva las filas (más en funciones de ventana):

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

Ahora se ve el patrón: mismo momento, misma acción, pero la segunda copia llegó más tarde. En los 10.689 pares, el reenvío llegó entre 1 segundo y 10 minutos después del original, con una mediana de 24 segundos.

QUALIFY filtra por una función de ventana igual que HAVING filtra por un agregado. Lo admiten DuckDB, Snowflake, BigQuery y Databricks; PostgreSQL, MySQL y SQL Server no, así que ahí mete la ventana en una subconsulta y filtra fuera: SELECT * FROM (SELECT ..., COUNT(*) OVER (...) AS copies FROM app_events) x WHERE copies > 1.

Una alternativa más antigua es unir la tabla con el resultado del GROUP BY ... HAVING por todas las columnas de la clave. Funciona en cualquier motor, pero lee la tabla dos veces.

Conservar la primera fila con ROW_NUMBER

Para quitar duplicados, numera las copias dentro de cada grupo y quédate con la número 1. El ORDER BY dentro de OVER decide qué copia gana; aquí, la primera recibida, con event_id como desempate para que el resultado sea repetible:

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 da la tabla limpia de 1.484.034 eventos; WHERE rn > 1 da exactamente las 10.689 filas que hay que borrar. Sin un ORDER BY determinista, la base de datos puede quedarse con una copia distinta cada vez que ejecutas la consulta. La diferencia entre ROW_NUMBER y RANK para este caso se explica en ROW_NUMBER, RANK y DENSE_RANK.

Qué copia conservar depende de los datos. Con eventos reenviados, la más antigua es la original. En una tabla de clientes cargada varias veces, normalmente quieres la versión más reciente: ORDER BY updated_at DESC. Si las filas difieren en lo completas que están, puedes ordenar primero por «tiene email».

Por qué importa: métricas infladas

Los duplicados rara vez rompen una consulta; lo que hacen es inflar los recuentos sin hacer ruido. Compras por plataforma, en bruto y sin 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

Android es la plataforma con más reenvíos: su recuento bruto de compras está inflado en torno a un 1 %, más del doble que el 0,4 % de la web. Poco, pero suficiente para mover una tasa de conversión o la comparación de un test A/B entre plataformas.

DISTINCT ON: una fila por clave en PostgreSQL

PostgreSQL y DuckDB tienen un atajo para «la primera fila de cada grupo»: DISTINCT ON (clave) conserva la primera fila de cada clave según el ORDER BY. El ORDER BY debe empezar por las mismas columnas que el 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

Sobre toda la tabla devuelve las mismas 1.484.034 filas que rn = 1. DISTINCT ON es más corto, pero MySQL, SQL Server y Oracle no lo tienen, y solo sirve para «quedarse con una»; ROW_NUMBER también te deja quedarte con dos, o marcar duplicados sin quitarlos.

Un SELECT DISTINCT normal solo elimina duplicados cuando coinciden todas las columnas seleccionadas, así que sirve para copias exactas y aquí no hace nada, porque event_id y received_at son distintos.

Borrar filas duplicadas

Antes de borrar nada, ejecuta el SELECT que lista las víctimas y comprueba el número. Para los eventos reenviados, las filas que hay que eliminar son las de 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

El número coincide con extra_rows de la comprobación con GROUP BY, así que el borrado eliminará exactamente una copia de cada par. Cómo convertirlo en un DELETE depende del motor.

SQL Server

SQL Server permite borrar a través de una CTE que lee una sola tabla, y eso da la versión más corta:

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

MySQL rechaza un DELETE cuya subconsulta lee directamente de la misma tabla (error 1093, «You can’t specify target table for update in FROM clause»). En su lugar, haz un JOIN con una tabla derivada. En MySQL 8.0 y posteriores, con funciones de ventana:

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;

En MySQL 5.7, que no tiene funciones de ventana y sigue en muchos hostings compartidos, usa un self join: borra una fila cuando otra con la misma clave llegó 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));

PostgreSQL y DuckDB

Si la tabla tiene un ID único, borra por ID con la misma 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
);

Si una tabla de PostgreSQL no tiene ninguna columna única (copias exactas), usa la dirección física oculta de la fila, ctid, para distinguir las copias:

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

En DuckDB, reconstruir la tabla suele ser más sencillo que borrar de ella:

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;

Uses el motor que uses, ejecuta el borrado dentro de una transacción, compara el número de filas afectadas con tu SELECT y solo entonces haz commit. En tablas grandes, borrar por lotes o reconstruir la tabla evita bloqueos largos.

Que los duplicados no vuelvan

Borrar arregla el pasado; una restricción arregla el futuro. Un índice único sobre la clave de negocio hace que la base de datos rechace la segunda copia:

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

Después, haz que el proceso de carga tolere las repeticiones en lugar de fallar: INSERT ... ON CONFLICT DO NOTHING en PostgreSQL y DuckDB, INSERT IGNORE o INSERT ... ON DUPLICATE KEY UPDATE en MySQL, MERGE en SQL Server. Crear el índice falla mientras queden duplicados, así que limpia primero. Si no puedes tocar la tabla de origen, quita los duplicados en una vista o en un modelo intermedio con el patrón de ROW_NUMBER y haz que todos los informes (también los de Power BI o Looker Studio) lean de ahí.

Una trampa más: GROUP BY pone todos los NULL en un mismo grupo, así que las filas con NULL en una columna de la clave cuentan como duplicadas entre sí. Un self join con a.col = b.col nunca empareja NULL y se las salta. Un índice único trata los NULL como distintos en PostgreSQL por defecto (PostgreSQL 15 añadió NULLS NOT DISTINCT para cambiarlo), mientras que SQL Server, por defecto, solo permite un NULL en un índice único. Decide qué comportamiento quieres antes de fiarte del recuento. Más en NULL en SQL.

Preguntas frecuentes

¿Cómo encuentro registros duplicados en SQL?

Agrupa por las columnas que deberían ser únicas y filtra con HAVING COUNT(*) > 1: SELECT col1, col2, COUNT(*) FROM t GROUP BY col1, col2 HAVING COUNT(*) > 1. Para ver las filas completas, usa COUNT(*) OVER (PARTITION BY col1, col2) y filtra donde sea mayor que 1.

¿Cómo elimino duplicados y me quedo con una fila?

Numera las filas de cada grupo con ROW_NUMBER() OVER (PARTITION BY clave ORDER BY ...) y borra las que tengan un número mayor que 1. El ORDER BY decide qué copia sobrevive, así que hazlo determinista, por ejemplo la marca de tiempo más antigua más el ID como desempate.

¿Cómo quito duplicados sin borrarlos?

Selecciona solo la primera copia: WHERE rn = 1 sobre una subconsulta con ROW_NUMBER, DISTINCT ON en PostgreSQL y DuckDB, o SELECT DISTINCT si las filas son idénticas en todas las columnas. Mételo en una vista para que los informes lean la versión limpia mientras la tabla original queda intacta.

¿Por qué SELECT DISTINCT no elimina mis duplicados?

DISTINCT compara todas las columnas seleccionadas. Si alguna es distinta, como un ID o una marca de tiempo de carga, las filas no son idénticas y se quedan las dos. Selecciona solo las columnas de la clave, o usa ROW_NUMBER para elegir una fila completa por clave.

¿Cuál es la forma más rápida de borrar duplicados en una tabla grande?

Borrar por ID una pequeña parte de las filas suele ir bien. Cuando hay muchos duplicados, a menudo es más rápido crear una tabla nueva solo con las filas rn = 1, sustituir la antigua y añadir un índice único para que el problema no vuelva.

También en English, Português (Brasil), Tiếng Việt, 简体中文