Indice / Entrada y salida / 7.3
Excel con openpyxl
Leer y escribir .xlsx con varias hojas, y darles el
formato mínimo para que alguien las pueda abrir. Excel es el formato en el que
llegan los datos y en el que los quieren de vuelta.
Para qué sirve
Es la realidad del trabajo: los datos llegan en una planilla que armó una persona - con un título arriba, una fila en blanco y la tabla más abajo - y el resultado hay que entregarlo también en una planilla, con varias hojas y el encabezado en negrita.
pandas hace las dos puntas con read_excel y to_excel.
openpyxl es el motor que trabaja abajo, y se usa directamente solo
para lo que pandas no hace: estilo, anchos de columna, paneles congelados.
ELI5Explicado facil
Un CSV es texto plano: lo que ves es lo que hay.
Un .xlsx es una carpeta comprimida con varias hojas
adentro, más los colores, las fórmulas y los anchos de columna. Por eso hace falta una
biblioteca para leerlo, y por eso una hoja puede tener cosas que no son la tabla.
Varias hojas
import tempfile
from pathlib import Path
import pandas as pd
inventario = pd.read_csv("inventario_productos.csv")
caros = inventario[inventario["precio_unitario"] > 500]
baratos = inventario[inventario["precio_unitario"] <= 500]
# Los ejemplos del curso no escriben en la carpeta de datos: usan un
# directorio temporal que se borra solo.
carpeta = Path(tempfile.mkdtemp())
destino = carpeta / "inventario.xlsx"
with pd.ExcelWriter(destino, engine="openpyxl") as libro:
caros.to_excel(libro, sheet_name="caros", index=False)
baratos.to_excel(libro, sheet_name="baratos", index=False)
print("archivo escrito:", destino.name)
print("tamano:", destino.stat().st_size > 0)
vuelta = pd.read_excel(destino, sheet_name="caros")
print()
print("hoja 'caros':", vuelta.shape)
print(vuelta.head(3).to_string(index=False))
print()
print("index=False es obligatorio en la practica: sin el, Excel abre el")
print("archivo con una primera columna sin nombre que confunde a todo el")
print("mundo, igual que en un CSV.")
archivo escrito: inventario.xlsx
tamano: True
hoja 'caros': (17, 5)
codigo producto categoria cantidad precio_unitario
P002 Ratón inalámbrico Perifericos 82 781.78
P003 Monitor 24 pulgadas Pantallas 108 872.23
P005 Webcam HD Perifericos 26 713.42
index=False es obligatorio en la practica: sin el, Excel abre el
archivo con una primera columna sin nombre que confunde a todo el
mundo, igual que en un CSV.
ExcelWriter y el with
Para escribir más de una hoja hace falta un ExcelWriter, y hay que
usarlo con with: el archivo se termina de armar recién al cerrarlo. Si lo
abrís sin with y te olvidás de close(), el
.xlsx queda corrupto.
Leer todas las hojas de una
import tempfile
from pathlib import Path
import pandas as pd
inventario = pd.read_csv("inventario_productos.csv")
carpeta = Path(tempfile.mkdtemp())
destino = carpeta / "por_categoria.xlsx"
# Una hoja por categoria. Los nombres de hoja tienen limites: 31 caracteres
# y sin los caracteres : \ / ? * [ ]
with pd.ExcelWriter(destino, engine="openpyxl") as libro:
for categoria, grupo in inventario.groupby("categoria"):
grupo.to_excel(libro, sheet_name=categoria[:31], index=False)
print("hojas escritas:", inventario["categoria"].nunique())
todas = pd.read_excel(destino, sheet_name=None)
print("tipo de lo que devuelve sheet_name=None:", type(todas).__name__)
print("hojas leidas:", len(todas))
print("nombres:", sorted(todas)[:5], "...")
print()
for nombre in sorted(todas)[:3]:
print(f"{nombre:<16} {todas[nombre].shape}")
print()
juntas = pd.concat(todas.values(), ignore_index=True)
print("apilando todas las hojas:", juntas.shape)
print("el original era :", inventario.shape)
print("coinciden:", len(juntas) == len(inventario))
hojas escritas: 11
tipo de lo que devuelve sheet_name=None: dict
hojas leidas: 11
nombres: ['Accesorios', 'Almacenamiento', 'Audio', 'Cables', 'Componentes'] ...
Accesorios (5, 5)
Almacenamiento (2, 5)
Audio (2, 5)
apilando todas las hojas: (35, 5)
el original era : (35, 5)
coinciden: True
sheet_name=None devuelve un diccionario de
DataFrames, uno por hoja. Con concat se apilan y se recupera el original
- el mismo patrón de la lección 6.1, pero con hojas en vez de archivos.
Máximo 31 caracteres, y no pueden contener
: \ / ? * [ ]. Si tu nombre viene de una columna de datos, hay que
recortarlo y limpiarlo, y ahí aparece un problema nuevo: dos categorías largas
distintas pueden quedar con el mismo nombre de hoja recortado.
Estilo
import tempfile
from pathlib import Path
import pandas as pd
from openpyxl import load_workbook
from openpyxl.styles import Alignment, Font
inventario = pd.read_csv("inventario_productos.csv").head(6)
carpeta = Path(tempfile.mkdtemp())
destino = carpeta / "con_estilo.xlsx"
inventario.to_excel(destino, sheet_name="inventario", index=False)
# pandas escribe los datos; openpyxl los viste. Se abre el archivo ya
# escrito, se toca lo que haga falta y se guarda.
libro = load_workbook(destino)
hoja = libro["inventario"]
for celda in hoja[1]: # la fila 1 es el encabezado
celda.font = Font(bold=True)
celda.alignment = Alignment(horizontal="center")
anchos = {"A": 10, "B": 24, "C": 16, "D": 10, "E": 16}
for columna, ancho in anchos.items():
hoja.column_dimensions[columna].width = ancho
hoja.freeze_panes = "A2" # el encabezado queda fijo
libro.save(destino)
print("hoja:", hoja.title)
print("filas:", hoja.max_row, "| columnas:", hoja.max_column)
print("encabezado en negrita:", hoja["A1"].font.bold)
print("ancho de B:", hoja.column_dimensions["B"].width)
print("paneles congelados en:", hoja.freeze_panes)
print()
print("Que se lee igual:", pd.read_excel(destino).shape)
print()
print("El estilo NO viaja en los datos: si despues lees el archivo con")
print("pandas, la negrita y el ancho no existen. Son para el humano que")
print("abre la planilla.")
hoja: inventario
filas: 7 | columnas: 5
encabezado en negrita: True
ancho de B: 24.0
paneles congelados en: A2
Que se lee igual: (6, 5)
El estilo NO viaja en los datos: si despues lees el archivo con
pandas, la negrita y el ancho no existen. Son para el humano que
abre la planilla.
La división de trabajo es clara: pandas escribe los datos, openpyxl los
viste. Se abre el archivo ya escrito con load_workbook, se toca
lo que haga falta y se guarda.
Y algo que conviene tener presente: el estilo no viaja en los datos. Si después leés ese archivo con pandas, la negrita y el ancho no existen. Son para la persona que abre la planilla, no para el programa que la procesa.
La planilla que armó una persona
import tempfile
from pathlib import Path
import pandas as pd
from openpyxl import Workbook
carpeta = Path(tempfile.mkdtemp())
destino = carpeta / "reporte.xlsx"
# Asi arma una planilla una persona: un titulo, una fila en blanco, y
# recien despues la tabla.
libro = Workbook()
hoja = libro.active
hoja.title = "Reporte"
hoja["A1"] = "Inventario al 24/08/2026"
hoja["A2"] = "Area: Sistemas"
hoja.append([])
hoja.append(["codigo", "producto", "cantidad"])
for fila in [["P001", "Teclado", 112], ["P002", "Raton", 82],
["P003", "Monitor", 108]]:
hoja.append(fila)
libro.save(destino)
print("leido sin decir nada:")
mal = pd.read_excel(destino)
print(mal.to_string())
print("columnas:", list(mal.columns))
print()
print("con skiprows=3:")
bien = pd.read_excel(destino, skiprows=3)
print(bien.to_string(index=False))
print("columnas:", list(bien.columns))
print("dtypes:")
print(bien.dtypes.to_string())
print()
print("Sin skiprows, el titulo se convierte en el nombre de la columna y")
print("las tres filas de datos quedan mezcladas con las de adorno. No da")
print("error: da una tabla que no sirve.")
leido sin decir nada:
Inventario al 24/08/2026 Unnamed: 1 Unnamed: 2
0 Area: Sistemas NaN NaN
1 NaN NaN NaN
2 codigo producto cantidad
3 P001 Teclado 112
4 P002 Raton 82
5 P003 Monitor 108
columnas: ['Inventario al 24/08/2026', 'Unnamed: 1', 'Unnamed: 2']
con skiprows=3:
codigo producto cantidad
P001 Teclado 112
P002 Raton 82
P003 Monitor 108
columnas: ['codigo', 'producto', 'cantidad']
dtypes:
codigo str
producto str
cantidad int64
Sin skiprows, el titulo se convierte en el nombre de la columna y
las tres filas de datos quedan mezcladas con las de adorno. No da
error: da una tabla que no sirve.
Sin skiprows, el título se convierte en el nombre de la primera
columna y las filas de adorno quedan mezcladas con los datos. No da
error: da una tabla que no sirve, con columnas llamadas
Unnamed: 1.
Unnamed: 1 es la firma de este problema
Cuando veas columnas con ese nombre, la fila de encabezado está en otro lado.
skiprows= para saltear filas de arriba, header= para decir
cuál fila es el encabezado, y usecols="B:E" si además sobran columnas a
la izquierda.
Dónde se rompe
Causa: pandas sabe leer Excel pero necesita el motor, que se instala aparte.
Arreglo: pip install openpyxl. Para archivos
.xls viejos el motor es otro (xlrd).
Causa: la hoja tiene un título o filas de adorno arriba de la tabla.
Arreglo: skiprows=3, o header=3. Abrí la
planilla y contá las filas: es más rápido que adivinar.
Causa: lo mismo que en CSV: pandas infiere que es un número.
Arreglo: dtype={"codigo": "str"}, que
read_excel también acepta.
Causa: el ExcelWriter no se cerró, así que el
.xlsx quedó incompleto.
Arreglo: usarlo siempre con with.
Causa: el nombre supera los 31 caracteres o tiene un carácter prohibido.
Arreglo: recortar y limpiar antes, y comprobar que no queden dos hojas con el mismo nombre después de recortar.
Acá hay que elegir
- CSV - cuando el destino es otro programa. Es texto
plano, se versiona en git, se compara con
diff, y no depende de ninguna biblioteca. Todos los objetivos de este curso son CSV por eso. - Excel - cuando el destino es una persona que va a abrir el archivo. Varias hojas, encabezado fijo, columnas anchas: cosas que un CSV no puede tener y que hacen la diferencia para quien lo lee.
Y si son las dos cosas, escribí los dos: el CSV es la fuente de verdad, el Excel es
la presentación. Lo que no conviene es que el .xlsx sea el único lugar
donde existe el resultado.
Recetario
| Expresión | Qué hace | Cuándo |
|---|---|---|
pd.read_excel(f) | Lee la primera hoja | El caso simple |
read_excel(f, sheet_name="ventas") | Una hoja por nombre | Cuando sabés cuál |
read_excel(f, sheet_name=None) | dict de DataFrames | Todas las hojas |
skiprows=3 | Saltea filas de arriba | Planillas con título |
usecols="B:E" | Solo ese rango de columnas | Cuando sobra a la izquierda |
dtype={"c": "str"} | Fuerza el tipo | Códigos con ceros |
df.to_excel(f, index=False) | Escribe una hoja | Salida simple |
with pd.ExcelWriter(f) as w: | Varias hojas | Siempre con with |
load_workbook(f) | Abre el archivo con openpyxl | Para darle estilo |
celda.font = Font(bold=True) | Negrita | Encabezados |
hoja.column_dimensions["B"].width = 24 | Ancho de columna | Para que se lea |
hoja.freeze_panes = "A2" | Fija el encabezado al scrollear | Tablas largas |