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

  1. Qué es CSV y por qué es la lingua franca de los datos tabulares
  2. Por qué no basta con split(",")
  3. Escribir con csv.writer (y el misterio de newline="")
  4. Leer con csv.reader
  5. DictReader y DictWriter: la forma preferida
  6. Round-trip completo: el catálogo de Papyrus a catalogo.csv y de vuelta
  7. El registro de ventas: LineaVenta cumple su destino
  8. Delimitadores, comillas y casos reales
  9. CSV a gran escala: una mención a pandas
  10. Errores comunes y consejos
  11. 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:

titulo,precio,stock
La Odisea,12.50,4
Hamlet,9.95,6
El Quijote,15.90,8
Fausto,21.00,0

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:

titulo,precio,stock
"Ana, la de las tejas verdes",11.75,3

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 libro

Paso a paso:

  • csv.writer(f) envuelve el archivo abierto (el with y el encoding="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 existe writerows(lista_de_filas) para volcar muchas de golpe.
  • newline="" es obligatorio al abrir un archivo para el módulo csv, tanto al escribir como al leer. ¿Por qué? El módulo csv gestiona é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 ocupa csv".

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 catalogo

Y 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 certifica

Ese 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:

fecha,titulo,importe
2026-07-13,Hamlet,9.83
2026-07-13,La Odisea,12.35
2026-07-13,El Quijote,16.54

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.72

Delimitadores, 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 distribuidora

En 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 dentro

El 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() para csv siempre lleva encoding="utf-8" y newline="".
  • Parsear CSV con split(","): funciona hasta el primer campo con coma o comillas. El módulo csv existe exactamente para eso; úsalo siempre, también para leer.
  • Olvidar las conversiones al leer: fila["precio"] es str. 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 hace cargar_catalogo().
  • Escribir la cabecera en cada append: si cada registrar_venta() llamara a writeheader(), el histórico se llenaría de cabeceras intercaladas. Cabecera solo al crear el archivo (el truco de Path.exists()).
  • DictWriter con claves que no están en fieldnames: writerow({"titulo": ..., "autor": ...}) lanza ValueError si autor no 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á con PermissionError. Cierra la hoja antes de ejecutar.
  • Consejo: define las columnas como constante compartida (COLUMNAS_VENTAS) y úsala tanto en fieldnames como 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 avanza

Mismo 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.54

Un 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 incluidas

En 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

Módulo 2: Estructuras de Control

Módulo 3: Funciones y Módulos

Módulo 4: Estructuras de Datos

Módulo 5: Programación Orientada a Objetos

Módulo 6: Manejo de Archivos

Módulo 7: Manejo de Errores y Excepciones

Módulo 8: Temas Avanzados

Módulo 9: Pruebas y Depuración

Módulo 10: Desarrollo Web con Python

Módulo 11: Ciencia de Datos con Python

Módulo 12: Proyecto Final

© Copyright 2026. Todos los derechos reservados