La lección anterior cerró con una autocrítica: el formato titulo | precio | stock de nuestro inventario es un dialecto casero que solo entiende nuestro programa, y que se rompe el día que un título contenga el separador. Hoy Papyrus se pasa a CSV (Comma-Separated Values), el formato tabular de texto que hablan Excel, Google Sheets, los bancos, las distribuidoras de libros y prácticamente cualquier sistema con el que Ana quiera intercambiar datos. Aprenderás el módulo csv de la biblioteca estándar — reader/writer y, sobre todo, DictReader/DictWriter —, resolverás los casos retorcidos (comas y comillas dentro de un campo) sin escribir una sola línea de parsing, y completarás dos hitos del hilo de Papyrus: el catálogo viajará a catalogo.csv y volverá como dict[str, Libro], y las LineaVenta de 05-06 acabarán donde aquella solución ya anunció que acabarían — en un CSV de ventas.
Contenido
- Qué es CSV y por qué es la lingua franca de los datos tabulares
- Por qué no basta con
split(",") - Escribir con
csv.writer(y el misterio denewline="") - Leer con
csv.reader DictReaderyDictWriter: la forma preferida- Round-trip completo: el catálogo de Papyrus a
catalogo.csvy de vuelta - El registro de ventas:
LineaVentacumple su destino - Delimitadores, comillas y casos reales
- CSV a gran escala: una mención a pandas
- Errores comunes y consejos
- Ejercicios con soluciones
Qué es CSV y por qué es la lingua franca de los datos tabulares
Un CSV es un archivo de texto plano — lo puedes abrir con cualquier editor, como los de 06-01 — que representa una tabla: una línea por fila, campos separados por comas, y habitualmente una primera línea de cabecera con los nombres de las columnas:
Su fuerza no es técnica sino social: todo el mundo lo entiende. Ana puede abrir catalogo.csv en Excel o Google Sheets y ver una hoja de cálculo; la distribuidora puede enviarle las novedades en CSV; y tu programa Python puede leer y escribir ambos. Cuando dos sistemas que no se conocen tienen que intercambiar tablas, CSV es el punto de encuentro — por eso se le llama lingua franca.
| Texto plano casero (06-01) | CSV | |
|---|---|---|
| Separador | Inventado (|) |
Convención universal (,) |
| Cabecera con nombres | No | Sí (habitual) |
| Lo abren Excel/Sheets | Como texto sin estructura | Como tabla |
| Campos con el separador dentro | Se rompe | Resuelto (comillas) |
| Soporte en Python | A mano (split) |
Módulo csv de la biblioteca estándar |
Por qué no basta con split(",")
La tentación es evidente: si los campos van separados por comas, linea.strip().split(",") (04-05) parece suficiente. Y lo es… hasta el primer título con coma:
Ese libro existe en Papyrus, y su título contiene una coma. La convención CSV lo resuelve entrecomillando el campo, pero entonces split(",") devuelve ['"Ana', ' la de las tejas verdes"', '11.75', '3'] — cuatro trozos donde había tres campos, y con comillas pegadas. Añade comillas dentro de comillas y el parsing casero se convierte en un pantano. La lección es general: no parsees formatos estándar a mano; usa el módulo que ya sabe hacerlo. Para CSV, ese módulo se llama csv y viene con Python.
Escribir con csv.writer (y el misterio de newline="")
import csv
libros = [
("La Odisea", 12.50, 4),
("Hamlet", 9.95, 6),
("El Quijote", 15.90, 8),
("Fausto", 21.00, 0),
]
with open("catalogo.csv", "w", encoding="utf-8", newline="") as f:
escritor = csv.writer(f)
escritor.writerow(["titulo", "precio", "stock"]) # la cabecera
for titulo, precio, stock in libros:
escritor.writerow([titulo, precio, stock]) # una fila por libroPaso a paso:
csv.writer(f)envuelve el archivo abierto (elwithy elencoding="utf-8"de 06-01 siguen mandando) y devuelve un escritor.writerow(lista)escribe una fila: convierte cada elemento a texto, pone las comas, entrecomilla lo que haga falta y añade el salto de línea. También existewriterows(lista_de_filas)para volcar muchas de golpe.newline=""es obligatorio al abrir un archivo para el módulocsv, tanto al escribir como al leer. ¿Por qué? El módulocsvgestiona él mismo los finales de línea (el estándar CSV usa\r\n); si no desactivas la traducción automática de saltos que Python hace en modo texto, en Windows ambas capas añaden la suya y aparecen líneas en blanco entre las filas. No lo memorices como magia:open(..., newline="")significa "no toques los saltos, que de eso se ocupacsv".
Fíjate en que escribimos 12.50 como número y csv.writer lo convirtió a texto por nosotros. La conversión de ida es automática; la de vuelta, como verás, no.
Leer con csv.reader
import csv
with open("catalogo.csv", "r", encoding="utf-8", newline="") as f:
lector = csv.reader(f)
cabecera = next(lector) # ['titulo', 'precio', 'stock'] — la saltamos
for fila in lector: # el lector es iterable, línea a línea (memoria constante)
print(fila)
# ['La Odisea', '12.50', '4']
# ['Hamlet', '9.95', '6']
# ...Cada fila llega como lista de cadenas — todas cadenas, también precio y stock. Es el mismo peaje de 06-01: el texto no recuerda tipos, y csv no adivina si "4" era un entero o un código postal. Convertir (float(fila[1]), int(fila[2])) sigue siendo trabajo tuyo. Y ahí asoma la debilidad de reader: los campos van por posición (fila[0], fila[1]…), así que si alguien reordena las columnas del CSV, tu código lee precios donde esperaba stocks sin quejarse. La solución es leer por nombre de columna.
DictReader y DictWriter: la forma preferida
DictReader usa la cabecera del archivo para entregarte cada fila como un diccionario columna → valor; DictWriter hace lo simétrico al escribir. Son la opción preferida en este curso: el código se lee solo y sobrevive a reordenaciones de columnas.
import csv
with open("catalogo.csv", "r", encoding="utf-8", newline="") as f:
lector = csv.DictReader(f) # usa la primera línea como cabecera
for fila in lector:
print(f'{fila["titulo"]}: {float(fila["precio"]):.2f} € — stock {fila["stock"]}')
# La Odisea: 12.50 € — stock 4
# Hamlet: 9.95 € — stock 6
# ...Y la escritura, declarando las columnas en fieldnames:
with open("catalogo.csv", "w", encoding="utf-8", newline="") as f:
escritor = csv.DictWriter(f, fieldnames=["titulo", "precio", "stock"])
escritor.writeheader() # escribe la cabecera por ti
escritor.writerow({"titulo": "La Odisea", "precio": 12.50, "stock": 4})reader/writer |
DictReader/DictWriter |
|
|---|---|---|
| Cada fila es… | list (acceso por índice) |
dict (acceso por nombre) |
| Cabecera | La gestionas tú (next, writerow) |
Automática (fieldnames, writeheader) |
| Si reordenan columnas | Bug silencioso | Sigue funcionando |
| Legibilidad | fila[1] |
fila["precio"] |
| Cuándo usarlo | CSV sin cabecera, scripts mínimos | Por defecto |
Round-trip completo: el catálogo de Papyrus a catalogo.csv y de vuelta
Vamos al hito de la lección: guardar el catálogo canónico dict[str, Libro] (05-06) al cerrar la tienda y reconstruirlo — objetos incluidos — al abrirla. Es el mismo round-trip del ejercicio 2 de 06-01, pero en un formato que Excel entiende y sin separadores caseros:
import csv
from pathlib import Path
from dataclasses import dataclass
def normalizar_titulo(texto):
return texto.strip().casefold() # la clave canónica de 04-05/05-06
@dataclass
class Libro: # la dataclass de 05-06
titulo: str
precio: float
stock: int = 0
IVA_LIBROS = 0.04
DESCUENTO_SOCIO = 0.05
def __post_init__(self):
if self.precio < 0:
raise ValueError(f"Precio negativo: {self.precio}")
if self.stock < 0:
raise ValueError(f"Stock negativo: {self.stock}")
def precio_final(self, socio=False):
descuento = Libro.DESCUENTO_SOCIO if socio else 0
return round(self.precio * (1 - descuento) * (1 + Libro.IVA_LIBROS), 2)
def hay_stock(self):
return self.stock > 0
COLUMNAS_CATALOGO = ["titulo", "precio", "stock"]
def guardar_catalogo(catalogo, ruta="catalogo.csv"):
"""Vuelca el catálogo completo a CSV. Modo 'w': se regenera entero."""
with open(ruta, "w", encoding="utf-8", newline="") as f:
escritor = csv.DictWriter(f, fieldnames=COLUMNAS_CATALOGO)
escritor.writeheader()
for libro in catalogo.values():
escritor.writerow({"titulo": libro.titulo,
"precio": libro.precio,
"stock": libro.stock})
def cargar_catalogo(ruta="catalogo.csv"):
"""Reconstruye el dict[str, Libro] desde CSV. str → float/int: el peaje de vuelta."""
if not Path(ruta).exists(): # paliativo de 06-01; lo bueno llega en el módulo 7
return {}
catalogo = {}
with open(ruta, "r", encoding="utf-8", newline="") as f:
for fila in csv.DictReader(f):
libro = Libro(fila["titulo"], float(fila["precio"]), int(fila["stock"]))
catalogo[normalizar_titulo(libro.titulo)] = libro
return catalogoY la prueba de fuego, con las cifras canónicas de siempre:
libros = [Libro("La Odisea", 12.50, 4), Libro("Hamlet", 9.95, 6),
Libro("El Quijote", 15.90, 8), Libro("Fausto", 21.00, 0)]
catalogo = {normalizar_titulo(l.titulo): l for l in libros}
guardar_catalogo(catalogo) # ... la tienda cierra, el programa termina ...
recargado = cargar_catalogo() # ... a la mañana siguiente ...
print(recargado["hamlet"].precio_final(socio=True)) # 9.83
print(recargado["fausto"].hay_stock()) # False — Fausto sigue agotado
print(recargado == catalogo) # True — el __eq__ de la dataclass lo certificaEse True final es el momento clave del módulo: el catálogo que renace del disco es indistinguible del que murió anoche, campo a campo, gracias al __eq__ generado por @dataclass (05-06). Además, al reconstruir cada Libro, __post_init__ vuelve a validar: si alguien editó el CSV en Excel y puso un precio negativo, el programa lo detecta en la carga, no tres ventas después.
El registro de ventas: LineaVenta cumple su destino
La solución 2 de 05-06 terminaba con una profecía: «en el módulo 6 estas líneas de venta serán exactamente lo que escribamos en un CSV». Cumplámosla. LineaVenta es la dataclass congelada y ordenable de entonces, y el registro de ventas — a diferencia del catálogo — es un histórico: crece con cada venta y jamás se reescribe, así que toca modo "a" (06-01):
import csv
from pathlib import Path
from dataclasses import dataclass
VENTAS = "ventas.csv"
COLUMNAS_VENTAS = ["fecha", "titulo", "importe"]
@dataclass(frozen=True, order=True)
class LineaVenta: # exactamente la de 05-06
fecha: str
titulo: str
importe: float
def registrar_venta(linea, ruta=VENTAS):
"""Añade una venta al histórico. Cabecera solo si el archivo aún no existe."""
nuevo = not Path(ruta).exists()
with open(ruta, "a", encoding="utf-8", newline="") as f:
escritor = csv.DictWriter(f, fieldnames=COLUMNAS_VENTAS)
if nuevo:
escritor.writeheader() # la cabecera se escribe una sola vez en la vida del archivo
escritor.writerow({"fecha": linea.fecha, "titulo": linea.titulo, "importe": linea.importe})
registrar_venta(LineaVenta("2026-07-13", "Hamlet", 9.83)) # Luis, con carné
registrar_venta(LineaVenta("2026-07-13", "La Odisea", 12.35)) # Marta, con carné
registrar_venta(LineaVenta("2026-07-13", "El Quijote", 16.54)) # Júlia, sin carnéContenido de ventas.csv tras la jornada:
Y el informe de caja se reduce a leer, reconstruir y agregar — sum con una expresión generadora (02-04) sobre las líneas del día:
def total_del_dia(fecha, ruta=VENTAS):
if not Path(ruta).exists():
return 0.0
with open(ruta, "r", encoding="utf-8", newline="") as f:
lineas = [LineaVenta(fila["fecha"], fila["titulo"], float(fila["importe"]))
for fila in csv.DictReader(f)]
return round(sum(l.importe for l in lineas if l.fecha == fecha), 2)
print(total_del_dia("2026-07-13")) # 38.72Delimitadores, comillas y casos reales
No todos los "CSV" usan comas. En países donde el decimal es la coma (12,50), Excel exporta con punto y coma; otros sistemas usan tabuladores (formato TSV). El módulo csv lo parametriza con delimiter:
with open("novedades_distribuidora.csv", "r", encoding="utf-8", newline="") as f:
lector = csv.DictReader(f, delimiter=";") # el CSV "a la europea" de la distribuidoraEn cuanto a las comillas: cuando un campo contiene el delimitador, saltos de línea o comillas, el estándar lo envuelve en comillas dobles, y las comillas internas se duplican (""). La buena noticia es que no tienes que gestionarlo: writer entrecomilla cuando hace falta y reader desentrecomilla al leer. Compruébalo con los dos libros problemáticos de Papyrus:
with open("dificiles.csv", "w", encoding="utf-8", newline="") as f:
escritor = csv.writer(f)
escritor.writerow(["titulo", "precio", "stock"])
escritor.writerow(["Ana, la de las tejas verdes", 11.75, 3]) # coma dentro
escritor.writerow(['Historia del "Quijote" apócrifo', 18.20, 2]) # comillas dentroEl archivo resultante (ábrelo en tu editor, como en 06-01):
titulo,precio,stock "Ana, la de las tejas verdes",11.75,3 "Historia del ""Quijote"" apócrifo",18.20,2
Y al releerlo con csv.reader, cada título vuelve exacto, con su coma y sus comillas simples — el pantano que split(",") no sabía cruzar, resuelto de serie. Si algún sistema exige entrecomillar siempre, existe quoting=csv.QUOTE_ALL como argumento del escritor; para este curso, el comportamiento por defecto (QUOTE_MINIMAL) es el correcto.
CSV a gran escala: una mención a pandas
El módulo csv lee fila a fila y es perfecto para los volúmenes de Papyrus. Cuando en el módulo 11 analicemos datos de verdad — miles o millones de filas, filtros, agrupaciones, estadísticas — usaremos pandas, cuya función read_csv() carga un CSV completo en una estructura tabular (el DataFrame) con conversión de tipos automática incluida. Es la herramienta de análisis; csv es la de fontanería ligera. Cada cosa a su tiempo: aquí basta con que sepas que existe y que lo aprenderás en 11-03.
Errores Comunes y Consejos
- Olvidar
newline=""al abrir: en Windows aparecen líneas en blanco entre filas (al escribir) o filas fantasma (al leer). Regla mecánica:open()paracsvsiempre llevaencoding="utf-8"ynewline="". - Parsear CSV con
split(","): funciona hasta el primer campo con coma o comillas. El módulocsvexiste exactamente para eso; úsalo siempre, también para leer. - Olvidar las conversiones al leer:
fila["precio"]esstr. Sumar"9.83" + "12.35"concatena ("9.8312.35") en vez de sumar — un bug especialmente traicionero porque no lanza error. Convierte al reconstruir el objeto, como hacecargar_catalogo(). - Escribir la cabecera en cada append: si cada
registrar_venta()llamara awriteheader(), el histórico se llenaría de cabeceras intercaladas. Cabecera solo al crear el archivo (el truco dePath.exists()). DictWritercon claves que no están enfieldnames:writerow({"titulo": ..., "autor": ...})lanzaValueErrorsiautorno se declaró. Las columnas se pactan una vez, en la constante (COLUMNAS_CATALOGO), y todos los que escriben la respetan.- Abrir el CSV en Excel mientras tu programa lo escribe: Excel bloquea el archivo en Windows y tu
open()fallará conPermissionError. Cierra la hoja antes de ejecutar. - Consejo: define las columnas como constante compartida (
COLUMNAS_VENTAS) y úsala tanto enfieldnamescomo al construir los diccionarios. Un solo lugar que cambiar si el formato evoluciona.
Ejercicios
Ejercicio 1: reposición de la distribuidora
La distribuidora envía reposicion.csv con cabecera titulo;unidades y delimitador ; (créalo tú a mano con estas filas: Fausto;5 y Hamlet;2). Escribe aplicar_reposicion(catalogo, ruta) que lo lea con DictReader, sume las unidades al stock del libro correspondiente usando normalizar_titulo() para casar títulos, e imprima un aviso por cada título que no esté en el catálogo. Comprueba que Fausto pasa de 0 a 5 ejemplares — y que hay_stock() por fin dice True.
Ejercicio 2: informe de caja como CSV
Escribe informe_caja(fecha, ruta_ventas, ruta_informe) que lea ventas.csv, se quede con las líneas de la fecha dada y escriba un nuevo CSV con columnas titulo,unidades,importe_total — una fila por título, agregada con un Counter o un defaultdict(float) (04-06). Pruébalo con las tres ventas canónicas del 2026-07-13 más una segunda venta de Hamlet a 10.35 (Omar, sin carné): Hamlet debe salir con 2 unidades y 20.18 de importe total.
Ejercicio 3: el catálogo indestructible
Amplía el round-trip: añade al catálogo el libro Ana, la de las tejas verdes (11.75, 3 ejemplares) y Historia del "Quijote" apócrifo (18.20, 2), ejecuta guardar_catalogo() + cargar_catalogo() y comprueba con == que el catálogo recargado es idéntico al original. Abre catalogo.csv en tu editor y localiza cómo quedó entrecomillado cada caso.
Soluciones
Solución 1:
import csv
def aplicar_reposicion(catalogo, ruta="reposicion.csv"):
with open(ruta, "r", encoding="utf-8", newline="") as f:
for fila in csv.DictReader(f, delimiter=";"):
clave = normalizar_titulo(fila["titulo"])
libro = catalogo.get(clave) # el buscar_libro de siempre (05-06)
if libro is None:
print(f"AVISO: {fila['titulo']!r} no está en el catálogo")
else:
libro.stock += int(fila["unidades"])
aplicar_reposicion(catalogo)
print(catalogo["fausto"].stock) # 5
print(catalogo["fausto"].hay_stock()) # True — la cola de reservas de Fausto por fin avanzaMismo DictReader, distinto dialecto: delimiter=";" absorbe la diferencia y el resto del código ni se entera.
Solución 2:
import csv
from collections import defaultdict
def informe_caja(fecha, ruta_ventas="ventas.csv", ruta_informe="informe.csv"):
unidades = defaultdict(int)
importes = defaultdict(float)
with open(ruta_ventas, "r", encoding="utf-8", newline="") as f:
for fila in csv.DictReader(f):
if fila["fecha"] == fecha:
unidades[fila["titulo"]] += 1
importes[fila["titulo"]] += float(fila["importe"])
with open(ruta_informe, "w", encoding="utf-8", newline="") as f:
escritor = csv.DictWriter(f, fieldnames=["titulo", "unidades", "importe_total"])
escritor.writeheader()
for titulo in unidades:
escritor.writerow({"titulo": titulo,
"unidades": unidades[titulo],
"importe_total": round(importes[titulo], 2)})
registrar_venta(LineaVenta("2026-07-13", "Hamlet", 10.35)) # Omar, sin carné
informe_caja("2026-07-13")
# En informe.csv: Hamlet,2,20.18 · La Odisea,1,12.35 · El Quijote,1,16.54Un CSV que se lee y otro que se escribe en la misma función: el patrón entrada → agregación → salida que verás mil veces.
Solución 3:
catalogo[normalizar_titulo("Ana, la de las tejas verdes")] = Libro("Ana, la de las tejas verdes", 11.75, 3)
catalogo[normalizar_titulo('Historia del "Quijote" apócrifo')] = Libro('Historia del "Quijote" apócrifo', 18.20, 2)
guardar_catalogo(catalogo)
print(cargar_catalogo() == catalogo) # True — comas y comillas incluidasEn el archivo verás "Ana, la de las tejas verdes" (entrecomillado por la coma) y "Historia del ""Quijote"" apócrifo" (comillas duplicadas). Tú no escribiste ni una comilla: csv aplicó el estándar y lo deshizo al leer.
Conclusión
Papyrus ya habla el idioma tabular universal: csv.writer/csv.reader para lo básico y DictWriter/DictReader como opción preferida — columnas por nombre, cabecera automática —, siempre con encoding="utf-8" y newline="", con delimiter para los dialectos europeos y el entrecomillado resuelto de serie. El catálogo completa su round-trip (guardar_catalogo()/cargar_catalogo(), con el peaje str → float/int pagado en un solo sitio) y las LineaVenta de 05-06 cumplieron su destino anunciado en ventas.csv, un histórico en modo append. Pero CSV tiene un techo: solo sabe de tablas planas. ¿Cómo guardarías los socios de Papyrus con su lista de compras dentro, o la configuración de la tienda con valores de distintos tipos? Necesitas un formato que entienda estructuras anidadas — diccionarios dentro de listas dentro de diccionarios — y que además recuerde si un valor era número, texto o booleano. Ese formato es JSON, el idioma nativo de las APIs web y los archivos de configuración, y es la próxima lección.
Curso de Programación en Python
Módulo 1: Introducción a Python
- Introducción a Python
- Configuración del Entorno de Desarrollo
- Sintaxis de Python y Tipos de Datos Básicos
- Variables y Constantes
- Entrada y Salida Básica
- Entornos Virtuales y Gestión de Paquetes
Módulo 2: Estructuras de Control
- Sentencias Condicionales
- Bucles: for y while
- Herramientas de Control de Flujo
- Comprensiones de Listas
Módulo 3: Funciones y Módulos
- Definición de Funciones
- Argumentos de Función
- Funciones Lambda
- Módulos y Paquetes
- Visión General de la Biblioteca Estándar
Módulo 4: Estructuras de Datos
Módulo 5: Programación Orientada a Objetos
Módulo 6: Manejo de Archivos
- Lectura y Escritura de Archivos
- Trabajo con Archivos CSV
- Manejo de Datos JSON
- Operaciones con Archivos y Directorios
Módulo 7: Manejo de Errores y Excepciones
- Introducción a las Excepciones
- Manejo de Excepciones
- Lanzamiento de Excepciones
- Excepciones Personalizadas
- Buenas Prácticas y Registro de Errores con logging
Módulo 8: Temas Avanzados
- Anotaciones de Tipos (type hints)
- Decoradores
- Generadores
- Administradores de Contexto
- Concurrencia: Hilos y Procesos
- Asyncio para Programación Asíncrona
Módulo 9: Pruebas y Depuración
- Introducción a las Pruebas
- Pruebas Unitarias con unittest
- Pruebas con pytest
- Desarrollo Guiado por Pruebas
- Técnicas de Depuración
- Uso de pdb para Depuración
Módulo 10: Desarrollo Web con Python
- Introducción al Desarrollo Web
- Fundamentos del Framework Flask
- Construcción de APIs REST con Flask
- Introducción a Django
- Construcción de Aplicaciones Web con Django
Módulo 11: Ciencia de Datos con Python
- Introducción a la Ciencia de Datos
- NumPy para Computación Numérica
- Pandas para Manipulación de Datos
- Matplotlib para Visualización de Datos
- Introducción al Aprendizaje Automático con scikit-learn
