Indice / Entrada y salida / 7.4
Escribir el resultado
La última línea del programa, y la que decide si otro puede reproducir tu trabajo. Todo el convenio de contratos de este curso sale de esta lección.
Para qué sirve
Escribir un CSV parece la parte trivial. No lo es: es donde se decide el orden de las columnas, cuántos decimales lleva cada número, cómo se escribe un ausente y si el archivo se puede volver a leer igual.
Si esas cuatro cosas no están fijadas, dos personas que resuelven bien el mismo problema entregan archivos distintos y ninguna puede compararse con la otra. Ese es exactamente el problema que resuelven los contratos de las prácticas.
ELI5Explicado facil
Es la diferencia entre contarle a alguien una receta y escribirla. Al contarla decís «un poco de sal»; al escribirla tenés que decidir si son 5 gramos o una cucharadita.
Escribir el archivo obliga a tomar decisiones que mientras trabajabas podías dejar en el aire.
Los tres parámetros de siempre
import tempfile
from pathlib import Path
import pandas as pd
inventario = pd.read_csv("inventario_productos.csv").head(3)
carpeta = Path(tempfile.mkdtemp())
por_defecto = carpeta / "por_defecto.csv"
inventario.to_csv(por_defecto)
print("to_csv(destino) a secas:")
print(por_defecto.read_text(encoding="utf-8"))
bien = carpeta / "bien.csv"
inventario.to_csv(bien, index=False, encoding="utf-8", lineterminator="\n")
print("con index=False:")
print(bien.read_text(encoding="utf-8"))
print("La primera columna sin nombre del primer archivo es el INDICE.")
print("Al volver a leerlo aparece como una columna llamada 'Unnamed: 0',")
print("y en la vuelta siguiente ya son dos.")
vuelta = pd.read_csv(por_defecto)
print()
print("columnas al releer el primero:", list(vuelta.columns))
print("columnas al releer el segundo:", list(pd.read_csv(bien).columns))
to_csv(destino) a secas:
,codigo,producto,categoria,cantidad,precio_unitario
0,P001,Teclado mecánico,Perifericos,112.0,4.53
1,P002,Ratón inalámbrico,Perifericos,82.0,781.78
2,P003,Monitor 24 pulgadas,Pantallas,108.0,872.23
con index=False:
codigo,producto,categoria,cantidad,precio_unitario
P001,Teclado mecánico,Perifericos,112.0,4.53
P002,Ratón inalámbrico,Perifericos,82.0,781.78
P003,Monitor 24 pulgadas,Pantallas,108.0,872.23
La primera columna sin nombre del primer archivo es el INDICE.
Al volver a leerlo aparece como una columna llamada 'Unnamed: 0',
y en la vuelta siguiente ya son dos.
columnas al releer el primero: ['Unnamed: 0', 'codigo', 'producto', 'categoria', 'cantidad', 'precio_unitario']
columnas al releer el segundo: ['codigo', 'producto', 'categoria', 'cantidad', 'precio_unitario']
index=False, siempre, salvo que el índice signifique algo
Sin él, to_csv escribe una primera columna sin nombre. Al releer el
archivo aparece como Unnamed: 0, y si alguien lo vuelve a escribir sin
pensar, ya son dos. Es la basura más común de los CSV que circulan por ahí.
Si el índice sí significa algo, reset_index() primero: así sale con su
nombre en vez de en una columna anónima.
El redondeo
import tempfile
from pathlib import Path
import pandas as pd
carpeta = Path(tempfile.mkdtemp())
tabla = pd.DataFrame({
"sku": ["A1", "A2"],
"precio": [10.126, 20.994], # pesos: 2 decimales
"coeficiente": [0.123456, 0.987654], # un factor: 4 decimales
})
# float_format se aplica a TODAS las columnas float a la vez.
con_format = carpeta / "con_format.csv"
tabla.to_csv(con_format, index=False, float_format="%.2f",
lineterminator="\n")
print('con float_format="%.2f":')
print(con_format.read_text(encoding="utf-8"))
print("El coeficiente perdio dos decimales que hacian falta.")
# Redondear por columna, en el DataFrame, deja cada una con lo suyo.
redondeada = tabla.assign(precio=tabla["precio"].round(2),
coeficiente=tabla["coeficiente"].round(4))
por_columna = carpeta / "por_columna.csv"
redondeada.to_csv(por_columna, index=False, lineterminator="\n")
print("redondeando por columna:")
print(por_columna.read_text(encoding="utf-8"))
print("Y por eso los contratos de este curso declaran el redondeo COLUMNA")
print("POR COLUMNA, y la comparacion es numerica y no byte a byte.")
con float_format="%.2f":
sku,precio,coeficiente
A1,10.13,0.12
A2,20.99,0.99
El coeficiente perdio dos decimales que hacian falta.
redondeando por columna:
sku,precio,coeficiente
A1,10.13,0.1235
A2,20.99,0.9877
Y por eso los contratos de este curso declaran el redondeo COLUMNA
POR COLUMNA, y la comparacion es numerica y no byte a byte.
float_format parece la solución y no lo es: se aplica a
todas las columnas float a la vez. En el ejemplo el
coeficiente, que necesitaba 4 decimales, sale con 2.
La regla del curso es .round(N) por columna, en el
DataFrame, antes de escribir. Y como consecuencia, los contratos
no prometen igualdad byte a byte: la comparación es numérica.
Los ausentes
import tempfile
from pathlib import Path
import pandas as pd
carpeta = Path(tempfile.mkdtemp())
tabla = pd.DataFrame({
"sku": ["A1", "A2", "A3"],
"precio": [10.5, None, 30.0],
"nota": ["ok", None, "revisar"],
})
normal = carpeta / "normal.csv"
tabla.to_csv(normal, index=False, lineterminator="\n")
print("por defecto, el ausente es un campo vacio:")
print(normal.read_text(encoding="utf-8"))
con_na_rep = carpeta / "na_rep.csv"
tabla.to_csv(con_na_rep, index=False, na_rep="NULO", lineterminator="\n")
print('con na_rep="NULO":')
print(con_na_rep.read_text(encoding="utf-8"))
print("Todos los contratos del curso piden el default: campo vacio.")
print("Un token propio obliga a declararlo tambien al leer.")
print()
print("Ahora, de donde sale la palabra 'nan' en un archivo. En pandas 3")
print("astype(str) NO la produce, al contrario de lo que dice casi toda")
print("la documentacion que vas a encontrar:")
print(" astype(str) :", list(tabla["nota"].astype(str)))
print(" map(str) :", list(tabla["nota"].map(str)))
print(" f-string :", list(tabla["precio"].map(lambda v: f"${v}")))
print()
malo = carpeta / "malo.csv"
tabla.assign(nota=tabla["nota"].map(str)).to_csv(
malo, index=False, lineterminator="\n")
print("escrito despues de map(str):")
print(malo.read_text(encoding="utf-8"))
vuelta = pd.read_csv(malo)
print("al releer, esa celda es un ausente?", bool(vuelta["nota"].isna().iloc[1]))
print("Es la CADENA 'nan'. Y como 'nan' esta en la lista de tokens que")
print("read_csv reconoce por defecto, al releer vuelve a ser un ausente:")
print("el error se tapa solo hasta que alguien abre el archivo en Excel.")
por defecto, el ausente es un campo vacio:
sku,precio,nota
A1,10.5,ok
A2,,
A3,30.0,revisar
con na_rep="NULO":
sku,precio,nota
A1,10.5,ok
A2,NULO,NULO
A3,30.0,revisar
Todos los contratos del curso piden el default: campo vacio.
Un token propio obliga a declararlo tambien al leer.
Ahora, de donde sale la palabra 'nan' en un archivo. En pandas 3
astype(str) NO la produce, al contrario de lo que dice casi toda
la documentacion que vas a encontrar:
astype(str) : ['ok', nan, 'revisar']
map(str) : ['ok', 'nan', 'revisar']
f-string : ['$10.5', '$nan', '$30.0']
escrito despues de map(str):
sku,precio,nota
A1,10.5,ok
A2,,nan
A3,30.0,revisar
al releer, esa celda es un ausente? True
Es la CADENA 'nan'. Y como 'nan' esta en la lista de tokens que
read_csv reconoce por defecto, al releer vuelve a ser un ausente:
el error se tapa solo hasta que alguien abre el archivo en Excel.
Todas las guías dicen que astype(str) convierte un ausente en la
cadena "nan". En pandas 3 ya no: lo conserva como
ausente.
Lo que sí sigue produciendo la palabra nan en el archivo es
map(str), apply(str) y cualquier f-string sobre un valor
ausente. Y el error se tapa solo: como nan está en la lista de tokens que
read_csv reconoce, al releer vuelve a ser un ausente. Se nota recién
cuando alguien abre el archivo en Excel.
Fechas y redondeo
import tempfile
from pathlib import Path
import pandas as pd
carpeta = Path(tempfile.mkdtemp())
pedidos = pd.read_csv("tienda_pedidos.csv", parse_dates=["creado"]).head(3)
normal = carpeta / "fechas.csv"
pedidos[["id", "creado"]].to_csv(normal, index=False, lineterminator="\n")
print("por defecto, la fecha sale completa:")
print(normal.read_text(encoding="utf-8"))
solo_dia = carpeta / "solo_dia.csv"
pedidos[["id", "creado"]].to_csv(solo_dia, index=False,
date_format="%Y-%m-%d", lineterminator="\n")
print('con date_format="%Y-%m-%d":')
print(solo_dia.read_text(encoding="utf-8"))
print("Se perdio la hora. Es lo que se queria, pero hay que quererlo.")
print()
# Y el redondeo de Python, que sorprende a todo el mundo alguna vez.
print("round() usa redondeo al par, no 'medio para arriba':")
for valor in [0.5, 1.5, 2.5, 3.5, 2.675]:
print(f" round({valor}) = {round(valor)}")
print()
print("round(2.675, 2) =", round(2.675, 2), " (y no 2.68)")
print("Los dos motivos son distintos: el primero es la regla de redondeo")
print("al par; el segundo es que 2.675 en binario no es exactamente 2.675.")
print()
serie = pd.Series([0.5, 1.5, 2.5, 3.5])
print("pandas hace lo mismo:", serie.round().tolist())
por defecto, la fecha sale completa:
id,creado
101,2026-07-08 22:39:00
102,2026-04-18 02:08:00
103,2026-02-22 13:19:00
con date_format="%Y-%m-%d":
id,creado
101,2026-07-08
102,2026-04-18
103,2026-02-22
Se perdio la hora. Es lo que se queria, pero hay que quererlo.
round() usa redondeo al par, no 'medio para arriba':
round(0.5) = 0
round(1.5) = 2
round(2.5) = 2
round(3.5) = 4
round(2.675) = 3
round(2.675, 2) = 2.67 (y no 2.68)
Los dos motivos son distintos: el primero es la regla de redondeo
al par; el segundo es que 2.675 en binario no es exactamente 2.675.
pandas hace lo mismo: [0.0, 2.0, 2.0, 4.0]
round() redondea al par
round(0.5) da 0 y round(1.5) da
2. No es un bug: es la regla de redondeo bancario, que evita el sesgo
hacia arriba de redondear siempre los medios en la misma dirección. pandas y NumPy
hacen lo mismo.
Y aparte, round(2.675, 2) da 2.67 porque
2.675 en binario es un poquito menos que 2,675. Son dos motivos
distintos y los dos van a sorprenderte alguna vez.
La comprobación de ida y vuelta
import tempfile
from pathlib import Path
import pandas as pd
carpeta = Path(tempfile.mkdtemp())
destino = carpeta / "salida.csv"
COLUMNAS = ["codigo", "producto", "cantidad", "precio_unitario"]
TIPOS = {"codigo": "str", "producto": "str", "cantidad": "Int64",
"precio_unitario": "float64"}
inventario = pd.read_csv("inventario_productos.csv")
salida = (inventario[COLUMNAS]
.assign(cantidad=lambda d: d["cantidad"].astype("Int64"),
precio_unitario=lambda d: d["precio_unitario"].round(2))
.sort_values("codigo", kind="stable")
.reset_index(drop=True))
salida.to_csv(destino, index=False, encoding="utf-8", lineterminator="\n")
# Leer con los MISMOS tipos con los que se escribio: si no, la inferencia
# mete diferencias que no son errores del programa.
vuelta = pd.read_csv(destino, dtype=TIPOS)
print("escrito :", salida.shape)
print("releido :", vuelta.shape)
print("columnas iguales:", list(salida.columns) == list(vuelta.columns))
try:
pd.testing.assert_frame_equal(salida, vuelta)
print("assert_frame_equal: OK, ida y vuelta sin perdidas")
except AssertionError as error:
print("assert_frame_equal fallo:")
print(str(error).splitlines()[0])
print()
print("Esta comprobacion es exactamente lo que hace comparar.py, y es la")
print("razon por la que los contratos declaran el tipo de cada columna:")
print("sin los tipos declarados, la vuelta puede diferir sin que nadie")
print("haya hecho nada mal.")
escrito : (35, 4)
releido : (35, 4)
columnas iguales: True
assert_frame_equal: OK, ida y vuelta sin perdidas
Esta comprobacion es exactamente lo que hace comparar.py, y es la
razon por la que los contratos declaran el tipo de cada columna:
sin los tipos declarados, la vuelta puede diferir sin que nadie
haya hecho nada mal.
Escribir y volver a leer con los mismos tipos tiene que devolver
lo mismo. Es exactamente lo que hace comparar.py, y la razón por la que
los contratos declaran el tipo de cada columna: sin esa declaración, la vuelta puede
diferir sin que nadie haya hecho nada mal.
Dónde se rompe
Causa: faltó index=False.
Arreglo: agregarlo. Y si el archivo ya circuló así,
read_csv(..., index_col=0) para recuperarlo sin la columna
basura.
Causa: float_format, que se aplica a todas las columnas
float por igual.
Arreglo: .round(N) por columna, en el DataFrame.
Causa: map(str), apply(str) o una f-string
sobre un valor ausente.
Arreglo: decidir qué se escribe en ese caso antes de convertir a
texto: fillna(""), o no convertir.
Causa: Excel en Windows abre los CSV con la codificación del sistema, no con UTF-8.
Arreglo: encoding="utf-8-sig", que agrega una marca al
principio que Excel entiende. El archivo sigue siendo UTF-8 y pandas lo lee
igual.
Causa: en Windows, escribir con fin de línea del sistema sobre un archivo abierto en modo texto duplica el retorno de carro.
Arreglo: lineterminator="\n", que además hace que el archivo
sea idéntico en todos los sistemas - que es lo que hace comparables los
objetivos de este curso.
Acá hay que elegir
Cinco decisiones. Ninguna la puede tomar pandas por vos, y las cinco están en todos los contratos del curso:
- Qué columnas y en qué orden -
df[COLUMNAS]con la lista en una constante. - Qué orden de filas - terminando en una columna única, para que no haya empates.
- Cuántos decimales por columna -
.round(N), columna por columna. - Cómo se escribe un ausente - campo vacío, salvo motivo.
- Qué tipo tiene cada columna al releer - para que la ida y vuelta cierre.
Escribirlas cuesta cinco minutos. No escribirlas cuesta la discusión de por qué dos archivos «iguales» no coinciden.
Recetario
| Expresión | Qué hace | Cuándo |
|---|---|---|
to_csv(f, index=False) | Sin la columna del índice | Siempre |
encoding="utf-8" | Codificación explícita | Siempre |
encoding="utf-8-sig" | Con marca para Excel | Si lo abre una persona en Windows |
lineterminator="\n" | Mismo fin de línea en todos los sistemas | Para archivos comparables |
df["c"].round(2) | Redondea esa columna | Por columna, antes de escribir |
na_rep="NULO" | Token propio para el ausente | Casi nunca: el vacío es mejor |
date_format="%Y-%m-%d" | Fechas sin hora | Cuando la hora no importa |
df[COLUMNAS] | Fija el orden de las columnas | Última línea antes de escribir |
reset_index(drop=True) | Renumera | Después de ordenar |
pd.testing.assert_frame_equal(a, b) | Compara dos tablas | La comprobación de ida y vuelta |
to_csv(f, sep=";", decimal=",") | CSV «europeo» | Si lo pide el destino |