Cómo eliminar correos inválidos y duplicados de un archivo CSV
Para eliminar correos electrónicos inválidos y duplicados de un CSV, pasa la columna de correo a minúsculas y quítale los espacios sobrantes, elimina los duplicados sobre ese valor limpio, descarta las direcciones que no superan una validación de sintaxis y, por último, descarta los dominios sin servidor de correo. Excel y Google Sheets pueden hacer los tres primeros pasos; un script corto de Python hace los cuatro y registra por qué se eliminó cada fila.
Las comprobaciones de sintaxis y de MX detectan errores de formato y dominios muertos. No pueden decirte si un buzón existe, porque para eso hace falta una conversación SMTP con el servidor del destinatario. Tómalo como la primera pasada gratuita antes de la verificación de verdad.
Qué puede hacer cada método
| Tarea | Excel | Google Sheets | Script de Python de abajo |
|---|---|---|---|
| Minúsculas y recorte de espacios | Sí | Sí | Sí |
| Espacios de no separación | Necesita SUBSTITUTE(…, CHAR(160), " ") |
También necesita SUBSTITUTE |
Resuelto |
| Eliminar duplicados | Sí | Sí | Sí, conserva el primero |
| Validación de sintaxis | Regex solo en Microsoft 365 | Sí, REGEXMATCH |
Sí |
| Registrar por qué se eliminó cada fila | Manual | Manual | Automático |
| Comprobación MX | No | No | Sí, con dnspython |
| Existencia del buzón | No | No | No |
Para archivos de unos pocos miles de filas que limpias una sola vez, una hoja de cálculo basta. Para limpiezas repetidas, archivos muy grandes o cualquier caso en que necesites un registro de auditoría, usa el script.
En Excel o Google Sheets
Normaliza en una columna auxiliar, valida la sintaxis de esa columna y elimina los duplicados sobre ella. Tenemos guías completas y probadas para Excel y Google Sheets, así que aquí va la versión corta.
- Columna auxiliar:
=LOWER(TRIM(SUBSTITUTE(A2, CHAR(160), " "))). ElSUBSTITUTEestá ahí porque tanto la función TRIM (ESPACIOS) de Excel como la opción Recortar espacios en blanco de Sheets dejan intactos los espacios de no separación. - Sintaxis:
REGEXMATCHen Sheets yREGEXTESTen Excel para Microsoft 365, con el patrón que usa el script de abajo. Las versiones antiguas de Excel necesitan una fórmula alternativa más larga que, en nuestra prueba, dejó pasar cuatro direcciones mal formadas. - Elimina los duplicados sobre la columna auxiliar con Quitar duplicados (Microsoft documenta que Excel conserva la primera aparición) o con
UNIQUEen una hoja nueva. - Filtra las filas que no pasaron y guarda como CSV.
En Python: un script probado de 20 líneas
Este script limpia, valida y elimina duplicados en una sola pasada, y escribe las filas rechazadas en un archivo aparte con el motivo. Solo usa la biblioteca estándar de Python.
import csv, re, sys
PATTERN = re.compile(r"^[a-z0-9_%+'-]+(\.[a-z0-9_%+'-]+)*@[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*\.[a-z]{2,}$")
src, column = sys.argv[1], sys.argv[2] if len(sys.argv) > 2 else "email"
seen = set()
with open(src, newline="", encoding="utf-8-sig") as f, \
open("clean.csv", "w", newline="", encoding="utf-8") as ok, \
open("rejected.csv", "w", newline="", encoding="utf-8") as bad:
reader = csv.DictReader(f)
good = csv.DictWriter(ok, fieldnames=reader.fieldnames)
rejected = csv.DictWriter(bad, fieldnames=reader.fieldnames + ["reason"])
good.writeheader(); rejected.writeheader()
for row in reader:
email = (row.get(column) or "").replace(" ", " ").strip().lower()
reason = "empty" if not email else "bad syntax" if not PATTERN.match(email) else "duplicate" if email in seen else ""
if reason:
rejected.writerow({**row, "reason": reason})
continue
seen.add(email)
good.writerow({**row, column: email})
print(f"kept {len(seen)} unique addresses")
Ejecútalo con el nombre del archivo y, si la columna no se llama email, con el nombre de la columna:
python3 clean_emails.py leads.csv
python3 clean_emails.py leads.csv "Email Address"
Escribe clean.csv (todas las columnas originales, con el correo normalizado) y rejected.csv (las filas originales más una columna reason con el motivo: empty (vacío), bad syntax (sintaxis incorrecta) o duplicate (duplicado)).
Qué pasó cuando lo ejecutamos
Ejecutamos el script el 1 de octubre de 2026 (Python 3.14, macOS) sobre un archivo de prueba de 19 filas creado con los errores que aparecen en las exportaciones reales. Imprimió kept 4 unique addresses.
| Entrada | Resultado |
|---|---|
ana.silva@example.com |
Conservada (primera aparición) |
Ana.Silva@Example.com |
Rechazada: duplicate |
ana.silva@example.com + espacio final |
Rechazada: duplicate |
ana.silva@example.com + espacio de no separación |
Rechazada: duplicate |
o'brien@example.co.uk |
Conservada |
jo+news@example.com |
Conservada |
kai@sub.example.io |
Conservada |
ben@example |
Rechazada: bad syntax |
cara@@example.com |
Rechazada: bad syntax |
dev@example..com |
Rechazada: bad syntax |
eve@example,com |
Rechazada: bad syntax |
gus.@example.com |
Rechazada: bad syntax |
max@-example.com |
Rechazada: bad syntax |
ned@example.c |
Rechazada: bad syntax |
mailto:finn@example.com |
Rechazada: bad syntax |
Pia <pia@example.com> |
Rechazada: bad syntax |
ola@example.com;ola2@example.com |
Rechazada: bad syntax |
hana o@example.com |
Rechazada: bad syntax |
| (vacía) | Rechazada: empty |
Las últimas cuatro filas rechazadas contienen direcciones reales envueltas en basura. Por eso el script escribe las filas rechazadas en un archivo en lugar de descartarlas: abre rejected.csv, corrige a mano lo que sea obviamente corregible y vuelve a añadir esas filas. No "corrijas" las ambiguas. hana o@example.com podría ser hanao@ o hana.o@, y adivinar crea una dirección que nadie te dio.
Dos trampas del CSV que el script evita
Separar por comas. La fila eve@example,com está guardada en el archivo como Eve,"eve@example,com", entre comillas porque contiene una coma. Lo comprobamos: un line.split(",") ingenuo la convierte en tres campos, ['Eve', '"eve@example', 'com"'], mientras que el módulo csv de Python devuelve correctamente dos. Usa siempre un analizador de CSV de verdad.
La marca de orden de bytes. Algunos programas escriben tres bytes invisibles, una marca de orden de bytes (BOM), al principio de los archivos UTF-8. Si lo abres como utf-8 sin más, Python lee el primer encabezado como 'email', así que una búsqueda de email no encuentra nada y todas las filas parecen vacías. Lo reprodujimos con un archivo de prueba de dos líneas. encoding="utf-8-sig" elimina la marca si está y no hace nada si no está.
Una nota sobre el espacio de no separación: el strip() de Python ya lo trata como espacio en blanco, a diferencia de las funciones de recorte de las hojas de cálculo. El replace explícito también detecta uno que esté en medio de una dirección, que entonces falla correctamente la validación de sintaxis.
Añadir una comprobación MX
Una comprobación de dominio elimina las direcciones de dominios que no pueden recibir correo, y es barata porque cada dominio se consulta una sola vez. Necesita un paquete: pip install dnspython.
import csv, sys
import dns.resolver # pip install dnspython
cache = {}
def mail_status(domain):
if domain in cache:
return cache[domain]
try:
answers = dns.resolver.resolve(domain, "MX", lifetime=5)
hosts = [str(r.exchange) for r in answers]
status = "null MX (accepts no mail)" if hosts == ["."] else "has MX"
except dns.resolver.NXDOMAIN:
status = "domain does not exist"
except dns.resolver.NoAnswer:
try:
dns.resolver.resolve(domain, "A", lifetime=5)
status = "no MX, A record fallback"
except Exception:
status = "no mail server"
except Exception:
status = "lookup failed, retry"
cache[domain] = status
return status
with open(sys.argv[1], newline="", encoding="utf-8") as f:
for row in csv.DictReader(f):
email = row["email"]
print(f"{email:45} {mail_status(email.rsplit('@', 1)[1])}")
Ejecutado sobre un archivo de prueba de cinco filas, con dnspython 2.8.0 el 1 de octubre de 2026, devolvió:
someone@gmail.com has MX
someone@example.com null MX (accepts no mail)
someone@nonexistent-domain-zz91x.com domain does not exist
someone@neverssl.com no MX, A record fallback
other@gmail.com has MX
Contrastamos cada resultado con dig. Dos de estas filas explican por qué el script es más largo que un simple "¿tiene registro MX?":
- MX nulo.
example.compublica un registro MX, pero es0 ., que el RFC 7505 define como la señal de que un dominio no acepta correo. Una comprobación que solo pregunta "¿hay un registro MX?" lo da por bueno. - Registro A como alternativa.
neverssl.comno tiene registro MX, pero sí un registro A. El resumen del RFC 7505 describe la entrega de correo como "primero buscando un registro MX y después buscando un registro A/AAAA como alternativa", así que la falta de MX por sí sola no demuestra que un dominio no pueda recibir correo. Marca estos casos para verificarlos en lugar de eliminarlos.
Elimina domain does not exist (el dominio no existe), null MX (MX nulo) y no mail server (sin servidor de correo). Vuelve a ejecutar más tarde los lookup failed, retry (consulta fallida, reintentar), porque un tiempo de espera agotado en el DNS no dice nada sobre el dominio. La herramienta de consulta MX comprueba dominios sueltos en el navegador si quieres hacer verificaciones puntuales.
Lo que esto no hace
Ni el script ni una hoja de cálculo pueden decirte si un buzón existe. nobody-here-12345@gmail.com supera la validación de sintaxis y la comprobación MX, y rebota si ese buzón no existe. Confirmar un buzón requiere una conversación con el servidor de correo del destinatario en el puerto 25, que la mayoría de las redes domésticas y en la nube bloquean; en por qué los proveedores de nube bloquean el puerto 25 están nuestras mediciones. Tampoco puede detectar los dominios catch-all, que aceptan todas las direcciones.
Para ese paso, pasa el archivo limpio por un verificador. Como ya has eliminado los duplicados y los dominios muertos, pagas por comprobar menos filas.
En resumen
Normaliza antes de eliminar duplicados, usa un analizador de CSV de verdad, abre los archivos con utf-8-sig y conserva las filas rechazadas con su motivo en lugar de borrarlas. Añade una comprobación MX que entienda el MX nulo y el registro A como alternativa. Eso elimina gratis los errores de formato y los dominios muertos; las comprobaciones a nivel de buzón son el único paso que queda, y la calculadora de coste de verificación te muestra cuánto cuesta lo que queda.
Preguntas frecuentes
¿Cómo elimino correos duplicados de un CSV?
Primero pasa la columna de correo a minúsculas y quítale los espacios sobrantes; después elimina los duplicados sobre ese valor limpio, conservando la primera aparición. Si eliminas duplicados sobre la columna sin limpiar, se te escapan las copias que solo se diferencian en mayúsculas o en espacios ocultos.
¿Cómo elimino correos inválidos de un CSV?
Pasa cada dirección por una validación de sintaxis para descartar las mal formadas y luego consulta los registros MX de cada dominio para descartar los dominios que no pueden recibir correo. Para eliminar las direcciones cuyo buzón ya no existe necesitas un servicio de verificación a nivel SMTP; ningún script local ni hoja de cálculo puede hacerlo de forma fiable.
¿Por qué mi script dice que ninguna fila tiene correo?
A menudo porque el archivo empieza con una marca de orden de bytes (BOM), que algunos programas escriben al principio de los archivos UTF-8. Python lee entonces el primer encabezado como '\ufeffemail' en lugar de 'email'. Abrir el archivo con encoding='utf-8-sig' la elimina.
¿Es seguro pasar las direcciones de correo a minúsculas?
En la práctica, sí. El RFC 5321 técnicamente permite que la parte anterior a la @ distinga mayúsculas y minúsculas, pero dice que aprovechar eso dificulta la interoperabilidad y se desaconseja, y los proveedores principales no crean buzones que solo se diferencien en mayúsculas.
Verifica direcciones sin límite por 29,99 USD/mes
Comprobación SMTP real del buzón. Sin créditos ni cobro por correo.
Empezar