Capítulo 9 de 14 9 secciones 10 min

Archivos: CSV, Excel, JSON y la codificación

Abrir lo que te mandan sin romperlo, y por qué a veces ves "á" donde debería haber una á.

Los archivos se abren con with open, que los cierra solo aunque algo falle. Para CSV está el módulo csv de la librería estándar y para Excel hace falta openpyxl. Los caracteres raros tipo "á" en vez de "á" son un problema de codificación: el archivo se guardó en latin-1 y se está leyendo como UTF-8, o al revés.

Hola! Vamos por lo que te mandan de verdad

Hasta ahora los datos los escribíamos nosotras dentro del código. En el trabajo real te llegan en un archivo, casi siempre por correo, casi siempre con algún problema 😅

Este capítulo va de abrirlos sin romperlos. Y de la codificación, que es el dolor número uno de trabajar con datos en español y que casi nadie explica bien.

with open, y por qué siempre con with

import tempfile
from pathlib import Path

# Uso una carpeta temporal para no ensuciar la tuya
carpeta = Path(tempfile.mkdtemp())
archivo = carpeta / 'notas.txt'

with open(archivo, 'w', encoding='utf-8') as f:
    f.write('Primera línea\n')
    f.write('Segunda línea con tildes: áéíóú ñ\n')

with open(archivo, encoding='utf-8') as f:
    contenido = f.read()

print(contenido)
Primera línea
Segunda línea con tildes: áéíóú ñ

Tres cosas de ahí que valen para siempre:

  • 🔒 with cierra el archivo solo, aunque el código de dentro reviente. Sin él tienes que acordarte de cerrarlo, y no te vas a acordar.
  • ✍️ El modo: 'r' leer (es el que viene por defecto), 'w' escribir borrando lo que había, 'a' agregar al final.
  • 🌍 encoding='utf-8' siempre, al leer y al escribir. En un minuto te explico por qué.

Ese 'w' borra sin preguntar, así que ten cuidadito. Si querías agregar y pusiste 'w', te quedaste sin lo anterior 😬

Para leer línea por línea, que es lo que haces con archivos grandes:

with open(archivo, encoding='utf-8') as f:
    for numero, linea in enumerate(f, start=1):
        print(numero, repr(linea))
1 'Primera línea\n'
2 'Segunda línea con tildes: áéíóú ñ\n'

Fíjate en el \n al final de cada línea: viene incluido. Por eso casi siempre se hace linea.strip() antes de usarla.

Y esta forma de leer tiene una ventaja enorme: no carga el archivo entero en memoria. Con un archivo de dos gigas, f.read() te tumba la máquina y el bucle no.

La codificación, o por qué ves "á"

Esta es la parte más importante del capítulo. Presta atención dos minutos y te ahorras años de misterio 💜

Un archivo de texto no guarda letras, guarda números. La codificación es la tabla que dice qué número es cada letra.

Hay dos que te vas a encontrar:

  • 🌍 UTF-8. El estándar de hoy. Le entran todos los idiomas. Es lo que usa Python por defecto y lo que debes usar siempre que puedas.
  • 🗄️ latin-1 (también llamado ISO-8859-1 o cp1252). El de Windows en español, de toda la vida. Sigue saliendo de sistemas antiguos y de algunos Excel.

Y ahora mira lo que pasa cuando se confunden, que es exactamente lo que has visto mil veces sin saber por qué:

texto = 'Cañete, Trujillo, Ancón'

# Alguien lo guardo con latin-1
bytes_latin = texto.encode('latin-1')

# Y tu lo lees como si fuera utf-8
print(bytes_latin.decode('utf-8'))
UnicodeDecodeError: 'utf-8' codec can't decode byte 0xf1 in position 2: invalid continuation byte

Ese es el error que revienta. Pero hay un caso peor, el que no revienta:

# Guardado en utf-8 y leido como latin-1: no falla, sale mal
bytes_utf = texto.encode('utf-8')
print(bytes_utf.decode('latin-1'))
Cañete, Trujillo, Ancón

Ahí está 😱 Eso tiene nombre y se llama mojibake. El archivo se lee sin un solo error y las tildes salen convertidas en pares de símbolos raros.

La regla para reconocerlo: si ves "Ã" seguida de otro símbolo, es UTF-8 leído como latin-1. Y se arregla leyendo con la codificación correcta, no reemplazando caracteres a mano.

Cuando no sepas cuál es, este orden funciona casi siempre:

datos = texto.encode('latin-1')

for codificacion in ['utf-8', 'latin-1']:
    try:
        print(codificacion, '->', datos.decode(codificacion))
        break
    except UnicodeDecodeError:
        print(codificacion, '-> no')
utf-8 -> no
latin-1 -> Cañete, Trujillo, Ancón

Primero UTF-8, y si revienta, latin-1. En ese orden, porque latin-1 casi nunca falla (acepta cualquier byte) y por eso probarlo primero te esconde el problema.

CSV sin pandas

Sí, pandas lo hace más fácil, y aun así vale la pena conocer el módulo csv:

  • Es de la librería estándar
  • No carga el archivo entero en memoria
  • No se confunde con las comas dentro de un campo
import csv

ruta = carpeta / 'ventas.csv'
filas = [
    ['cliente', 'ciudad', 'monto'],
    ['C0045', 'Trujillo', '480.37'],
    ['C0325', 'Lima, Miraflores', '524.90'],
]

with open(ruta, 'w', newline='', encoding='utf-8') as f:
    csv.writer(f).writerows(filas)

print(ruta.read_text(encoding='utf-8'))
cliente,ciudad,monto
C0045,Trujillo,480.37
C0325,"Lima, Miraflores",524.90

Mira la última línea: como la ciudad tenía una coma dentro, el módulo la puso entre comillas sola. Si hubieras armado el CSV pegando textos con ','.join(), esa fila habría quedado con cuatro campos en vez de tres y nadie te habría avisado 🙃

Ese newline='' parece un detalle raro y hace falta: sin él, en Windows te salen líneas en blanco entre fila y fila.

Para leer, lo mejor es DictReader, que te da cada fila como diccionario usando la cabecera:

with open(ruta, encoding='utf-8') as f:
    for fila in csv.DictReader(f):
        print(fila['ciudad'], '->', float(fila['monto']) * 1.18)
Trujillo -> 566.8366
Lima, Miraflores -> 619.382

Pides por nombre de columna y no por posición, así que si mañana cambian el orden de las columnas tu código sigue funcionando 🌸

Excel

Para leer Excel hace falta instalar openpyxl (pip install openpyxl). Después es directo:

from openpyxl import Workbook, load_workbook

libro = Workbook()
hoja = libro.active
hoja.title = 'Ventas'

hoja.append(['cliente', 'ciudad', 'monto'])
hoja.append(['C0045', 'Trujillo', 480.37])
hoja.append(['C0325', 'Lima', 524.90])

ruta_xlsx = carpeta / 'ventas.xlsx'
libro.save(ruta_xlsx)

leido = load_workbook(ruta_xlsx)
print(leido.sheetnames)

for fila in leido['Ventas'].iter_rows(min_row=2, values_only=True):
    print(fila)
['Ventas']
('C0045', 'Trujillo', 480.37)
('C0325', 'Lima', 524.9)

Ese min_row=2 se salta la cabecera y values_only=True te da los valores en vez de los objetos de celda, que es lo que quieres el 99% de las veces.

Dos avisos sobre Excel, aprendidos a la mala:

  • 🧮 Las fórmulas no vienen calculadas. Por defecto lees la fórmula (=A2*1.18) y no su resultado. Con load_workbook(ruta, data_only=True) lees el último valor calculado, que existe solo si el archivo se abrió en Excel alguna vez.
  • 📅 Las fechas vienen como fechas si la celda tenía formato de fecha, y como texto si no. Siempre revisa el tipo antes de confiar.

JSON

import json

ventas = [
    {'cliente': 'C0045', 'ciudad': 'Trujillo', 'monto': 480.37},
    {'cliente': 'C0325', 'ciudad': 'Ñaña', 'monto': 524.90},
]

ruta_json = carpeta / 'ventas.json'
with open(ruta_json, 'w', encoding='utf-8') as f:
    json.dump(ventas, f, ensure_ascii=False, indent=2)

print(ruta_json.read_text(encoding='utf-8'))
[
  {
    "cliente": "C0045",
    "ciudad": "Trujillo",
    "monto": 480.37
  },
  {
    "cliente": "C0325",
    "ciudad": "Ñaña",
    "monto": 524.9
  }
]

Ese ensure_ascii=False es el que hace que la ñ se escriba como ñ. Sin él sale "\u00d1a\u00f1a", que es válido y es ilegible.

Y las cuatro funciones de json, que se confunden siempre:

FunciónQué hace
json.load(f)De archivo a Python
json.loads(texto)De texto a Python
json.dump(obj, f)De Python a archivo
json.dumps(obj)De Python a texto

La s del final es de "string". Con eso ya no las confundes 😄

Leer el archivo del libro

Vamos a leer el CSV de verdad con el que vamos a trabajar el resto del libro, todavía sin pandas:

from urllib.request import urlopen

URL = 'https://missyera.com/static/datasets/ventas-miss-yera.csv'

with urlopen(URL) as respuesta:
    texto = respuesta.read().decode('utf-8')

Y si ya lo descargaste, que es lo que vas a hacer casi siempre, se lee igual pero con open y la ruta de tu computadora:

import csv

ARCHIVO = 'ventas-miss-yera.csv'      # descargado al lado del cuaderno

with open(ARCHIVO, encoding='utf-8') as f:
    texto = f.read()

lineas = texto.splitlines()
print('filas (con cabecera):', len(lineas))
print(lineas[0])

lector = csv.DictReader(lineas)
primera = next(lector)
print(primera['ciudad'], primera['monto'], primera['compro'])
filas (con cabecera): 3038
id_venta,fecha,cliente_id,ciudad,segmento,canal,categoria,unidades,monto,descuento,fecha_ultima_compra,satisfaccion,compro,monto_final_facturado
Trujillo 480.37 1

Tres mil treinta y ocho líneas contando la cabecera, o sea 3.037 ventas. Y fíjate en algo: primera['monto'] devolvió '480.37' como texto, porque un CSV no guarda tipos. Todo lo que sale de ahí es texto hasta que tú lo conviertas.

Eso es exactamente lo que pandas te va a hacer solo en el capítulo 11, y ahora ya sabes que no es magia 🌟

Ejercicios

1. Escribir y volver a leer

Escribe tres líneas en un archivo y vuelve a leerlas contando cuántas son.

import tempfile
from pathlib import Path

ruta = Path(tempfile.mkdtemp()) / 'prueba.txt'

with open(ruta, 'w', encoding='utf-8') as f:
    for ciudad in ['Lima', 'Cusco', 'Piura']:
        f.write(ciudad + '\n')

with open(ruta, encoding='utf-8') as f:
    lineas = [l.strip() for l in f]

print(lineas, len(lineas))
['Lima', 'Cusco', 'Piura'] 3
2. El modo que borra

Escribe con 'w' dos veces y comprueba que la segunda borró la primera. Después hazlo con 'a'.

ruta2 = Path(tempfile.mkdtemp()) / 'cierre-de-caja.txt'

with open(ruta2, 'w', encoding='utf-8') as f:
    f.write('caja del lunes\n')
with open(ruta2, 'w', encoding='utf-8') as f:
    f.write('caja del martes\n')
print(repr(ruta2.read_text(encoding='utf-8')))

with open(ruta2, 'a', encoding='utf-8') as f:
    f.write('caja del miércoles\n')
print(repr(ruta2.read_text(encoding='utf-8')))
'caja del martes\n'
'caja del martes\ncaja del miércoles\n'
3. Reproduce el mojibake

Toma el texto "Cañete y José" y muéstralo mal leído a propósito.

t = 'Cañete y José'
print(t.encode('utf-8').decode('latin-1'))
Cañete y José

Cada letra con tilde se convirtió en dos símbolos. Cuando lo veas en un archivo, ya sabes qué pasó y no hace falta reemplazar nada a mano.

4. Detectar la codificación

Escribe una función que pruebe UTF-8 y después latin-1, y devuelva cuál funcionó.

def decodifica(datos):
    for cod in ['utf-8', 'latin-1']:
        try:
            return cod, datos.decode(cod)
        except UnicodeDecodeError:
            continue
    return None, None

print(decodifica('Cañete'.encode('utf-8')))
print(decodifica('Cañete'.encode('latin-1')))
('utf-8', 'Cañete')
('latin-1', 'Cañete')

Las dos salieron bien porque el orden es el correcto. Al revés, la primera habría dado mojibake sin quejarse.

5. La coma dentro del campo

Escribe un CSV donde un campo tenga una coma y comprueba que al leerlo siguen siendo tres columnas.

import csv

ruta3 = Path(tempfile.mkdtemp()) / 'comas.csv'
with open(ruta3, 'w', newline='', encoding='utf-8') as f:
    w = csv.writer(f)
    w.writerow(['id', 'direccion', 'monto'])
    w.writerow(['1', 'Av. Larco 123, Miraflores', '480.37'])

with open(ruta3, encoding='utf-8') as f:
    for fila in csv.reader(f):
        print(len(fila), fila)
3 ['id', 'direccion', 'monto']
3 ['1', 'Av. Larco 123, Miraflores', '480.37']
6. DictReader y un total

Con el CSV anterior, suma los montos convirtiéndolos a número.

with open(ruta3, encoding='utf-8') as f:
    total = sum(float(fila['monto']) for fila in csv.DictReader(f))

print(total)
480.37

Ese float() no es opcional: sin él estarías concatenando textos y el "total" saldría absurdo, como en el capítulo 2.

7. JSON con tildes

Guarda un diccionario con eñes en JSON, primero sin ensure_ascii=False y después con él.

import json

d = {'ciudad': 'Ñaña'}
print(json.dumps(d))
print(json.dumps(d, ensure_ascii=False))
{"ciudad": "\u00d1a\u00f1a"}
{"ciudad": "Ñaña"}

Los dos son JSON válido y cualquier programa lee los dos. El segundo lo lee además un ser humano 💜

8. Contar filas sin cargar el archivo

Cuenta las líneas de un archivo recorriéndolo, sin usar read().

ruta4 = Path(tempfile.mkdtemp()) / 'ventas-del-anio.txt'
with open(ruta4, 'w', encoding='utf-8') as f:
    for i in range(1000):
        f.write(f'venta {i}\n')

with open(ruta4, encoding='utf-8') as f:
    print(sum(1 for _ in f))
1000

Ese sum(1 for _ in f) cuenta sin guardar nada en memoria. Con mil líneas da igual; con veinte millones es la diferencia entre que funcione y que no.

Lo que te llevas

  • 🔒 with open(...) siempre, que cierra solo.
  • 🌍 encoding='utf-8' al leer y al escribir, siempre.
  • 👀 Si ves "Ã" seguida de un símbolo, es UTF-8 leído como latin-1.
  • 📄 Prueba UTF-8 primero y latin-1 después, en ese orden.
  • 🧾 Para CSV usa el módulo csv, que resuelve solo las comas dentro de un campo.
  • 🔤 Todo lo que sale de un CSV es texto hasta que tú lo conviertas.

En el capítulo 10 entra NumPy, y con él la razón por la que un cálculo sobre un millón de números puede tardar un segundo o tardar dos minutos.

Que tengas un hermoso día! 🌟

¿Tienes alguna duda o consulta?