Una lista de suscriptores importada de tres formularios acaba con correos repetidos: unos idénticos y otros que solo se distinguen por una mayúscula o un espacio. Encontrar los duplicados es fácil con GROUP BY; borrarlos sin perder la fila buena y conseguir que no vuelvan tiene más detalles, y aquí MySQL se comporta distinto que SQLite y PostgreSQL.
También te puede interesar
Los datos
Una tabla de suscriptores, creada para este ejemplo sobre la base «tienda»:
-- Suscriptores del boletín, importados de tres formularios distintos: hay correos repetidos, -- unos idénticos y otros con mayúsculas o espacios de más. CREATE TABLE suscriptores ( id INTEGER PRIMARY KEY, nombre VARCHAR(60) NOT NULL, email VARCHAR(80) NOT NULL, origen VARCHAR(20) NOT NULL ); INSERT INTO suscriptores (id, nombre, email, origen) VALUES (1, 'Ana Torres', 'ana.torres@ejemplo.com', 'web'), (2, 'Luis Fernández', 'luis.f@ejemplo.com', 'web'), (3, 'Camila Rojas', 'camila.r@ejemplo.com', 'web'), (4, 'Ana Torres', 'Ana.Torres@ejemplo.com', 'tienda'), (5, 'Diego Martínez', 'diego.m@ejemplo.com', 'tienda'), (6, 'Luis Fernández', 'luis.f@ejemplo.com', 'tienda'), (7, 'Valentina Díaz', 'vale.diaz@ejemplo.com', 'feria'), (8, 'Ana T.', ' ana.torres@ejemplo.com ', 'feria'), (9, 'Jorge Ramírez', 'jorge.r@ejemplo.com', 'feria'), (10, 'Camila Rojas', 'CAMILA.R@EJEMPLO.COM', 'feria');
Duplicados exactos
Se agrupa por la columna que debería ser única y se pide HAVING COUNT(*) > 1: HAVING filtra grupos, igual que WHERE filtra filas.
SELECT email, COUNT(*) AS veces FROM suscriptores GROUP BY email HAVING COUNT(*) > 1;
Ábrelo en el editor y cámbialo →
resultado · SQLiteemail | veces -------------------+------ luis.f@ejemplo.com | 2
SQLite y PostgreSQL encuentran un solo correo repetido. MySQL encuentra tres:
resultado · MySQL+------------------------+-------+ | email | veces | +------------------------+-------+ | ana.torres@ejemplo.com | 2 | | luis.f@ejemplo.com | 2 | | camila.r@ejemplo.com | 2 | +------------------------+-------+
No es un error: en MySQL 8, el juego de caracteres por defecto (utf8mb4_0900_ai_ci) compara sin distinguir mayúsculas ni tildes, así que Ana.Torres@… y ana.torres@… son el mismo valor para GROUP BY. El correo con un espacio delante, en cambio, sigue siendo distinto en los tres motores.
Duplicados después de normalizar
Para que los tres motores digan lo mismo, se agrupa por el correo normalizado: sin espacios a los lados (TRIM) y en minúsculas (LOWER):
SELECT LOWER(TRIM(email)) AS email_normalizado, COUNT(*) AS veces FROM suscriptores GROUP BY LOWER(TRIM(email)) HAVING COUNT(*) > 1 ORDER BY veces DESC, email_normalizado;
Ábrelo en el editor y cámbialo →
resultado · SQLiteemail_normalizado | veces -----------------------+------ ana.torres@ejemplo.com | 3 camila.r@ejemplo.com | 2 luis.f@ejemplo.com | 2
Ahora sí: Ana aparece tres veces y Camila y Luis, dos. Para ver las filas completas, y no solo los grupos, sirve COUNT(*) OVER (PARTITION BY …), que cuenta sin agrupar:
SELECT id, nombre, email, origen
FROM (
SELECT s.*,
COUNT(*) OVER (PARTITION BY LOWER(TRIM(email))) AS veces
FROM suscriptores s
) t
WHERE veces > 1
ORDER BY LOWER(TRIM(email)), id;Ábrelo en el editor y cámbialo →
resultado · SQLiteid | nombre | email | origen ---+----------------+--------------------------+------- 1 | Ana Torres | ana.torres@ejemplo.com | web 4 | Ana Torres | Ana.Torres@ejemplo.com | tienda 8 | Ana T. | ana.torres@ejemplo.com | feria 3 | Camila Rojas | camila.r@ejemplo.com | web 10 | Camila Rojas | CAMILA.R@EJEMPLO.COM | feria 2 | Luis Fernández | luis.f@ejemplo.com | web 6 | Luis Fernández | luis.f@ejemplo.com | tienda
Borrar los sobrantes y conservar uno
La regla más común es quedarse con la fila más antigua, la del id más bajo, y borrar el resto:
-- Se queda la fila más antigua (el id más bajo) de cada correo y se borran las demás DELETE FROM suscriptores WHERE id NOT IN ( SELECT MIN(id) FROM suscriptores GROUP BY LOWER(TRIM(email)) ); SELECT id, nombre, email FROM suscriptores ORDER BY id;
Ábrelo en el editor y cámbialo →
resultado · SQLiteid | nombre | email ---+----------------+----------------------- 1 | Ana Torres | ana.torres@ejemplo.com 2 | Luis Fernández | luis.f@ejemplo.com 3 | Camila Rojas | camila.r@ejemplo.com 5 | Diego Martínez | diego.m@ejemplo.com 7 | Valentina Díaz | vale.diaz@ejemplo.com 9 | Jorge Ramírez | jorge.r@ejemplo.com
PostgreSQL da el mismo resultado. MySQL se niega a ejecutar el DELETE (y la consulta siguiente muestra la tabla intacta):
resultado · MySQLERROR 1093 (HY000) at line 2: You can't specify target table 'suscriptores' for update in FROM clause +----+-----------------+--------------------------+ | id | nombre | email | +----+-----------------+--------------------------+ | 1 | Ana Torres | ana.torres@ejemplo.com | | 2 | Luis Fernández | luis.f@ejemplo.com | | 3 | Camila Rojas | camila.r@ejemplo.com | | 4 | Ana Torres | Ana.Torres@ejemplo.com | | 5 | Diego Martínez | diego.m@ejemplo.com | | 6 | Luis Fernández | luis.f@ejemplo.com | | 7 | Valentina Díaz | vale.diaz@ejemplo.com | | 8 | Ana T. | ana.torres@ejemplo.com | | 9 | Jorge Ramírez | jorge.r@ejemplo.com | | 10 | Camila Rojas | CAMILA.R@EJEMPLO.COM | +----+-----------------+--------------------------+
MySQL no deja leer en una subconsulta la misma tabla que se está borrando. La solución es envolver la subconsulta en una tabla derivada, que MySQL calcula entera antes de borrar:
-- En MySQL, la subconsulta va envuelta en una tabla derivada (AS conservar)
DELETE FROM suscriptores
WHERE id NOT IN (
SELECT id FROM (
SELECT MIN(id) AS id FROM suscriptores GROUP BY LOWER(TRIM(email))
) AS conservar
);
SELECT id, nombre, email FROM suscriptores ORDER BY id;resultado · MySQL+----+-----------------+------------------------+ | id | nombre | email | +----+-----------------+------------------------+ | 1 | Ana Torres | ana.torres@ejemplo.com | | 2 | Luis Fernández | luis.f@ejemplo.com | | 3 | Camila Rojas | camila.r@ejemplo.com | | 5 | Diego Martínez | diego.m@ejemplo.com | | 7 | Valentina Díaz | vale.diaz@ejemplo.com | | 9 | Jorge Ramírez | jorge.r@ejemplo.com | +----+-----------------+------------------------+
SELECT con las mismas condiciones, haz la operación dentro de una transacción (BEGIN … COMMIT) y ten una copia. Un DELETE no se puede deshacer una vez confirmado.Que no vuelva a pasar: un índice único
Un índice único sobre la expresión normalizada impide que entre un correo que ya existe con otras mayúsculas. SQLite y PostgreSQL aceptan índices sobre expresiones:
DELETE FROM suscriptores WHERE id NOT IN (SELECT MIN(id) FROM suscriptores GROUP BY LOWER(TRIM(email))); -- Índice único sobre el correo normalizado: un duplicado nuevo ya no entra CREATE UNIQUE INDEX suscriptores_email_unico ON suscriptores (LOWER(TRIM(email))); INSERT INTO suscriptores (id, nombre, email, origen) VALUES (11, 'Luis F.', 'Luis.F@Ejemplo.com', 'web');
Ábrelo en el editor y cámbialo →
resultado · SQLiteError: UNIQUE constraint failed: index 'suscriptores_email_unico'
resultado · PostgreSQLERROR: duplicate key value violates unique constraint "suscriptores_email_unico" DETAIL: Key (lower(TRIM(BOTH FROM email)))=(luis.f@ejemplo.com) already exists.
En MySQL (desde la 8.0.13) también se puede, pero la expresión va entre dobles paréntesis; con uno solo da un error de sintaxis:
DELETE FROM suscriptores WHERE id NOT IN (SELECT id FROM (SELECT MIN(id) AS id FROM suscriptores GROUP BY LOWER(TRIM(email))) AS conservar); -- Con un solo par de paréntesis, MySQL lo toma por una columna y da error CREATE UNIQUE INDEX suscriptores_email_unico ON suscriptores (LOWER(TRIM(email))); -- La expresión va entre dobles paréntesis CREATE UNIQUE INDEX suscriptores_email_unico ON suscriptores ((LOWER(TRIM(email)))); INSERT INTO suscriptores (id, nombre, email, origen) VALUES (11, 'Luis F.', 'Luis.F@Ejemplo.com', 'web');
resultado · MySQLERROR 1064 (42000) at line 5: You have an error in your SQL syntax; check the manual that corresponds to your MySQL server version for the right syntax to use near 'TRIM(email)))' at line 1 ERROR 1062 (23000) at line 10: Duplicate entry 'luis.f@ejemplo.com' for key 'suscriptores.suscriptores_email_unico'
El primer error es el índice mal escrito; el segundo, el que queríamos: el correo repetido ya no entra.
Todas las consultas de esta página se ejecutaron antes de publicarla sobre la base de ejemplo «tienda» del curso, en contenedores limpios: SQLite 3.46.1 (el de Python 3.13), PostgreSQL 17 y MySQL 8.4. Los resultados y los mensajes de error están copiados de esa ejecución. Documentación oficial: índices sobre expresiones en MySQL 8.4.