Indice / Repaso: archivos y errores / 1.3
CSV con el módulo csv
Un CSV no es «un archivo separado por comas»: es un formato con
reglas de entrecomillado que split(",") no conoce. El módulo
csv las conoce todas y viene en la biblioteca estándar.
Para qué sirve
Es el formato en el que van a llegar la mayoría de los datos de la materia, y el
que más veces se procesa mal. Partir la línea con split(",") funciona
hasta la primera fila que trae una coma adentro de un campo - y esa fila
siempre existe.
Además, esta lección es donde el dato deja de ser texto plano y pasa a ser fila con columnas nombradas. Ese salto es lo que la unidad 2 va a formalizar y lo que pandas da por hecho.
ELI5Explicado facil
Pensalo como una dirección postal escrita en un renglón. Si el nombre de la calle tiene una coma, hace falta una convención para decir «esta coma es parte del nombre, no una separación». Esa convención son las comillas.
El módulo csv es el que sabe leer y escribir esa convención sin
equivocarse. Vos nunca la implementás a mano.
Leer: cada fila es un diccionario
import csv
# newline="" no es un adorno: se lo pide la documentacion del modulo csv.
# El modulo maneja los finales de linea por su cuenta, y si open ademas
# los traduce, un campo con un salto adentro se parte al medio.
with open("tienda_clientes.csv", encoding="utf-8", newline="") as f:
lector = csv.DictReader(f)
print("columnas:", lector.fieldnames)
filas = list(lector)
print("filas:", len(filas), "(la cabecera NO cuenta como fila)")
print()
print("la primera fila es un dict:")
print(" ", filas[0])
print()
print("y se accede por nombre de columna, no por posicion:")
print(" filas[0]['nombre'] ->", filas[0]["nombre"])
print(" filas[0]['alta'] ->", filas[0]["alta"])
print()
print("Todo llega como texto. SIEMPRE. Incluso lo que parece numero o fecha:")
print(" type(filas[0]['id']) ->", type(filas[0]["id"]).__name__)
print()
# Comparado con hacerlo a mano: split(",") no sabe nada de comillas.
with open("tienda_clientes.csv", encoding="utf-8") as f:
lineas = f.read().splitlines()
print("la fila 35 del archivo, cruda:")
print(" ", lineas[35])
print("con split(',') el nombre queda: ", repr(lineas[35].split(",")[1]))
print("con csv el nombre queda: ", repr(filas[34]["nombre"]))
print("Dos comillas seguidas adentro de un campo entrecomillado significan")
print("una comilla literal. csv lo sabe; split no.")
print()
# csv.reader acepta cualquier iterable de lineas, asi que se puede
# probar una fila suelta sin tocar el disco.
suelta = 'P900,"Teclado mecanico, retroiluminado",Perifericos,12,4.53'
print("un campo con una coma adentro:")
print(" split(',') ->", suelta.split(","))
print(" csv ->", next(csv.reader([suelta])))
columnas: ['id', 'nombre', 'correo', 'alta']
filas: 40 (la cabecera NO cuenta como fila)
la primera fila es un dict:
{'id': '1', 'nombre': 'Ximena Herrera', 'correo': 'ximena.herrera@example.com', 'alta': '2026-05-18'}
y se accede por nombre de columna, no por posicion:
filas[0]['nombre'] -> Ximena Herrera
filas[0]['alta'] -> 2026-05-18
Todo llega como texto. SIEMPRE. Incluso lo que parece numero o fecha:
type(filas[0]['id']) -> str
la fila 35 del archivo, cruda:
35,"José ""Pepe"" Rodríguez",olivia.rios@example.com,2026-03-20
con split(',') el nombre queda: '"José ""Pepe"" Rodríguez"'
con csv el nombre queda: 'José "Pepe" Rodríguez'
Dos comillas seguidas adentro de un campo entrecomillado significan
una comilla literal. csv lo sabe; split no.
un campo con una coma adentro:
split(',') -> ['P900', '"Teclado mecanico', ' retroiluminado"', 'Perifericos', '12', '4.53']
csv -> ['P900', 'Teclado mecanico, retroiluminado', 'Perifericos', '12', '4.53']
newline="" en el open, siempre, tanto al
leer como al escribir. Lo pide la documentación del módulo: csv maneja
los finales de línea por su cuenta, y si open además los traduce, un
campo con un salto adentro se parte al medio.
Todo llega como str. Siempre. '2003' es
texto, '57.24' es texto, y una columna vacía es '', no
None. Convertir es trabajo tuyo, y es donde aparecen las excepciones de
la lección 1.2.
Escribir, y el newline="" otra vez
import csv
import tempfile
from pathlib import Path
carpeta = Path(tempfile.mkdtemp())
filas = [
{"id": "1", "nombre": "Ximena Herrera", "ciudad": "Salta"},
{"id": "2", "nombre": 'José "Pepe" Rodríguez', "ciudad": "San Juan"},
{"id": "3", "nombre": "Ana O'Connor", "ciudad": "Mar del Plata, BA"},
]
COLUMNAS = ["id", "nombre", "ciudad"]
bien = carpeta / "bien.csv"
with open(bien, "w", encoding="utf-8", newline="") as f:
escritor = csv.DictWriter(f, fieldnames=COLUMNAS)
escritor.writeheader()
escritor.writerows(filas)
print("escrito con newline='' :")
print(bien.read_text(encoding="utf-8"))
print("El modulo entrecomillo SOLO lo que lo necesitaba:")
print(" la comilla se duplico, la coma obligo a entrecomillar,")
print(" y el apostrofe no necesito nada.")
# Ahora lo mismo SIN newline="".
mal = carpeta / "mal.csv"
with open(mal, "w", encoding="utf-8") as f:
escritor = csv.DictWriter(f, fieldnames=COLUMNAS)
escritor.writeheader()
escritor.writerows(filas)
print()
print("bytes con newline='' :", len(bien.read_bytes()))
print("bytes sin newline='' :", len(mal.read_bytes()))
print("finales CR CR LF :", mal.read_bytes().count(b"\r\r\n"))
print()
print("csv escribe CR LF; sin newline='' open traduce ese LF a CR LF otra")
print("vez, y queda CR CR LF. Excel lo muestra con una fila vacia entre")
print("cada dos. Es el bug de CSV mas reportado, y son dos caracteres.")
print()
print("leido de vuelta, el que esta bien vuelve identico:")
with open(bien, encoding="utf-8", newline="") as f:
vuelta = list(csv.DictReader(f))
print(" ", vuelta == filas, "->", vuelta[1]["nombre"], "|", vuelta[2]["ciudad"])
escrito con newline='' :
id,nombre,ciudad
1,Ximena Herrera,Salta
2,"José ""Pepe"" Rodríguez",San Juan
3,Ana O'Connor,"Mar del Plata, BA"
El modulo entrecomillo SOLO lo que lo necesitaba:
la comilla se duplico, la coma obligo a entrecomillar,
y el apostrofe no necesito nada.
bytes con newline='' : 118
bytes sin newline='' : 122
finales CR CR LF : 4
csv escribe CR LF; sin newline='' open traduce ese LF a CR LF otra
vez, y queda CR CR LF. Excel lo muestra con una fila vacia entre
cada dos. Es el bug de CSV mas reportado, y son dos caracteres.
leido de vuelta, el que esta bien vuelve identico:
True -> José "Pepe" Rodríguez | Mar del Plata, BA
Es el bug de CSV más reportado del mundo y son dos caracteres. El módulo escribe
CR LF al final de cada fila; sin newline="", en Windows
open traduce ese LF a CR LF otra vez y el
archivo queda con CR CR LF. Excel lo interpreta como una fila en blanco
después de cada fila real.
El ejemplo lo muestra contando bytes: 118 contra 122, y cuatro
CR CR LF donde no debería haber ninguno.
Al escribir, csv entrecomilla solo lo que hace falta:
la coma dentro de Mar del Plata, BA obliga a entrecomillar, la comilla de
José "Pepe" Rodríguez se duplica, y el apóstrofe de
Ana O'Connor no necesita nada. Ese es exactamente el criterio que habría
que reimplementar a mano, y no vale la pena.
Las filas que no tienen la forma esperada
import csv
CABECERA = "isbn,titulo,autor,anio,precio"
# csv.reader y csv.DictReader aceptan cualquier iterable de lineas, no
# solo un archivo. Sirve para probar un caso raro sin tocar el disco.
raras = [
CABECERA,
"978-1000-001,Algoritmos vol. 1,Ximena Molina,2003,57.24",
"978-1000-002,Grafos,Ana Diaz,2010", # falta precio
"978-1000-003,Redes,Luis Paz,2011,40.00,EXTRA,OTRO", # sobran dos
]
print("con csv.reader: listas, y cada una con el largo que vino")
for fila in csv.reader(raras):
print(" ", len(fila), fila)
print()
print("con csv.DictReader: dicts, y la forma se completa o se junta")
for fila in csv.DictReader(raras):
print(" ", fila)
print()
print("Lo que falta queda en None y lo que sobra en la clave None.")
print("Ninguna de las dos cosas levanta excepcion: hay que buscarlas.")
print()
# restval y restkey le ponen nombre a esas dos situaciones.
lector = csv.DictReader(raras, restval="", restkey="sobrante")
for fila in lector:
faltan = [c for c, v in fila.items() if v == ""]
sobra = fila.get("sobrante")
if faltan or sobra:
print(" fila irregular:", fila["isbn"],
"| vacias:", faltan, "| de mas:", sobra)
print()
print("En el archivo real las 50 filas tienen las 5 columnas...")
with open("libros_catalogo.csv", encoding="utf-8", newline="") as f:
filas = list(csv.DictReader(f, restkey="sobrante", restval=None))
irregulares = [x for x in filas if "sobrante" in x or None in x.values()]
print(" filas:", len(filas), "| irregulares:", len(irregulares))
print("...pero eso es sobre la FORMA. El contenido es otra historia:")
NULOS = {"", "-", "N/A", "NaN", "null", "NA"}
vacias = sum(1 for x in filas for v in x.values() if v.strip() in NULOS)
print(" celdas con un token de ausente:", vacias)
print(" isbn distintos:", len({x['isbn'] for x in filas}), "de", len(filas))
con csv.reader: listas, y cada una con el largo que vino
5 ['isbn', 'titulo', 'autor', 'anio', 'precio']
5 ['978-1000-001', 'Algoritmos vol. 1', 'Ximena Molina', '2003', '57.24']
4 ['978-1000-002', 'Grafos', 'Ana Diaz', '2010']
7 ['978-1000-003', 'Redes', 'Luis Paz', '2011', '40.00', 'EXTRA', 'OTRO']
con csv.DictReader: dicts, y la forma se completa o se junta
{'isbn': '978-1000-001', 'titulo': 'Algoritmos vol. 1', 'autor': 'Ximena Molina', 'anio': '2003', 'precio': '57.24'}
{'isbn': '978-1000-002', 'titulo': 'Grafos', 'autor': 'Ana Diaz', 'anio': '2010', 'precio': None}
{'isbn': '978-1000-003', 'titulo': 'Redes', 'autor': 'Luis Paz', 'anio': '2011', 'precio': '40.00', None: ['EXTRA', 'OTRO']}
Lo que falta queda en None y lo que sobra en la clave None.
Ninguna de las dos cosas levanta excepcion: hay que buscarlas.
fila irregular: 978-1000-002 | vacias: ['precio'] | de mas: None
fila irregular: 978-1000-003 | vacias: [] | de mas: ['EXTRA', 'OTRO']
En el archivo real las 50 filas tienen las 5 columnas...
filas: 50 | irregulares: 0
...pero eso es sobre la FORMA. El contenido es otra historia:
celdas con un token de ausente: 4
isbn distintos: 49 de 50
csv.reader | csv.DictReader | |
|---|---|---|
| Devuelve | list de str | dict columna → valor |
| La cabecera | Es una fila más: la consumís con next(r) | La consume sola, queda en .fieldnames |
| Si faltan campos | La lista viene más corta | Los que faltan quedan en None (o en restval) |
| Si sobran campos | La lista viene más larga | Se juntan en la clave None (o en restkey) |
| Cuándo | Archivos sin cabecera, o cuando importa la posición | Casi siempre |
Ninguna de las dos irregularidades levanta excepción. Si el archivo puede venir
mal formado, hay que buscarlas: restkey y
restval les ponen un nombre para poder preguntar por ellas.
El ciclo completo
import csv
import tempfile
from pathlib import Path
NULOS = {"", "-", "N/A", "NaN", "null", "NA"}
SALIDA = ["isbn", "titulo", "autor", "anio", "precio"]
RECHAZOS = ["isbn", "columna", "motivo", "valor"]
def revisar(fila, vistos):
"""Devuelve (columna, motivo, valor) del primer defecto, o None."""
if fila["isbn"] in vistos:
return ("isbn", "duplicado", fila["isbn"])
for columna in ("titulo", "autor"):
if fila[columna].strip() in NULOS:
return (columna, "ausente", fila[columna])
if fila["anio"].strip() in NULOS:
return ("anio", "ausente", fila["anio"])
try:
anio = int(fila["anio"])
except ValueError:
return ("anio", "no_numerico", fila["anio"])
if not 1400 <= anio <= 2026:
return ("anio", "fuera_de_rango", fila["anio"])
if fila["precio"].strip() in NULOS:
return ("precio", "ausente", fila["precio"])
return None
with open("libros_catalogo.csv", encoding="utf-8", newline="") as f:
filas = list(csv.DictReader(f))
buenas, malas, vistos = [], [], set()
for fila in filas:
defecto = revisar(fila, vistos)
if defecto is None:
vistos.add(fila["isbn"])
buenas.append(fila)
else:
columna, motivo, valor = defecto
malas.append({"isbn": fila["isbn"], "columna": columna,
"motivo": motivo, "valor": valor})
carpeta = Path(tempfile.mkdtemp())
for nombre, columnas, datos in (("libros_validos.csv", SALIDA, buenas),
("libros_rechazados.csv", RECHAZOS, malas)):
with open(carpeta / nombre, "w", encoding="utf-8", newline="") as f:
escritor = csv.DictWriter(f, fieldnames=columnas, extrasaction="ignore")
escritor.writeheader()
escritor.writerows(datos)
print("entrada :", len(filas), "filas")
print("validos :", len(buenas))
print("rechazos:", len(malas))
print("suma :", len(buenas) + len(malas), "== entrada:",
len(buenas) + len(malas) == len(filas))
print()
print(Path(carpeta / "libros_rechazados.csv").read_text(encoding="utf-8"))
print("Ese assert de la suma es la invariante mas util de todas: si no da,")
print("hay una fila que no fue ni aceptada ni rechazada, y se perdio callada.")
entrada : 50 filas
validos : 44
rechazos: 6
suma : 50 == entrada: True
isbn,columna,motivo,valor
978-2045-006,precio,ausente,
978-3226-020,anio,fuera_de_rango,2199
978-8634-027,anio,ausente,
978-1000-001,isbn,duplicado,978-1000-001
978-6834-041,autor,ausente,
978-4222-048,precio,ausente,NaN
Ese assert de la suma es la invariante mas util de todas: si no da,
hay una fila que no fue ni aceptada ni rechazada, y se perdio callada.
Esta es la forma que van a tener casi todos los ejercicios de la unidad: leer con
DictReader, decidir fila por fila, y escribir dos
archivos - lo válido y lo rechazado con su motivo. Fijate en tres detalles que
no son adorno:
extrasaction="ignore"en elDictWriterdeja pasar diccionarios que traen claves de más, escribiendo solo las defieldnames. Sin eso, una clave extra esValueError.- Las columnas de salida se declaran en una constante, arriba. El orden de las columnas del archivo es el de esa lista, no el del diccionario.
len(validos) + len(rechazos) == len(entrada)es la invariante más útil del curso: si no da, hay una fila que no fue ni aceptada ni rechazada, y se perdió sin que nadie se entere.
Dónde se rompe
Causa: falta newline="" en el open de
escritura.
Arreglo: open(ruta, "w", encoding="utf-8", newline="").
Causa: el diccionario que le pasás al DictWriter tiene más
claves que las declaradas.
Arreglo: extrasaction="ignore", o armar el diccionario de
salida con solo las columnas que van.
Causa: comparaste con un número algo que vino del CSV, y del CSV todo viene como texto.
Arreglo: convertir primero, con su try/except ValueError.
"9" > "10" además es True: comparar textos ordena
alfabéticamente.
Causa: la cabecera trae un espacio (' nombre') o un BOM
pegado a la primera columna ('isbn'), típico de un CSV
exportado de Excel.
Arreglo: mirar lector.fieldnames. Para el BOM,
encoding="utf-8-sig".
Causa: una comilla sin cerrar hace que el módulo se coma el resto del archivo como un solo campo.
Arreglo: mirar el archivo crudo cerca de esa línea. El defecto está en los datos, no en tu código.
Recetario
| Expresión | Qué hace | Cuándo |
|---|---|---|
open(r, encoding="utf-8", newline="") | La apertura correcta | Leer y escribir CSV, siempre |
csv.DictReader(f) | Filas como dict | Casi siempre |
lector.fieldnames | Los nombres de columna leídos | Primer diagnóstico ante un KeyError |
csv.reader(f) | Filas como list | Sin cabecera, o si importa la posición |
next(lector) | Consume una fila | Saltear la cabecera con reader |
DictReader(f, restval="", restkey="extra") | Nombra lo que falta y lo que sobra | Archivos que pueden venir mal formados |
csv.DictWriter(f, fieldnames=COLS) | Escritor por nombre | Al escribir |
escritor.writeheader() | Escribe la cabecera | Antes de las filas; se olvida seguido |
escritor.writerows(filas) | Escribe todas | En vez de un for |
extrasaction="ignore" | Tolera claves de más | Cuando la fila trae datos de trabajo |
delimiter=";" | Otro separador | CSV «europeo» (lección 7.1) |
encoding="utf-8-sig" | Descarta el BOM al leer | CSV exportado de Excel |