Saltar al contenido

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

EjemploLos tres parametros que van siempre en to_csv
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

EjemploRedondear por columna, no con float_format
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

EjemploComo se escribe un ausente, y como aparece la palabra "nan"
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.
Otro caso donde pandas 3 cambió

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

EjemploFechas al escribir, y el redondeo que sorprende
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

EjemploLa comprobacion 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

(sin error) el CSV tiene una primera columna sin nombre

Causa: faltó index=False.

Arreglo: agregarlo. Y si el archivo ya circuló así, read_csv(..., index_col=0) para recuperarlo sin la columna basura.

(sin error) una columna perdió decimales que hacían falta

Causa: float_format, que se aplica a todas las columnas float por igual.

Arreglo: .round(N) por columna, en el DataFrame.

(sin error) el archivo tiene la palabra nan en algunas celdas

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.

(sin error) los acentos salen mal al abrir en Excel

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.

(sin error) el archivo tiene líneas en blanco entre cada fila

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

Qué hay que fijar antes de escribir

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

Escribir el resultado
ExpresiónQué haceCuándo
to_csv(f, index=False)Sin la columna del índiceSiempre
encoding="utf-8"Codificación explícitaSiempre
encoding="utf-8-sig"Con marca para ExcelSi lo abre una persona en Windows
lineterminator="\n"Mismo fin de línea en todos los sistemasPara archivos comparables
df["c"].round(2)Redondea esa columnaPor columna, antes de escribir
na_rep="NULO"Token propio para el ausenteCasi nunca: el vacío es mejor
date_format="%Y-%m-%d"Fechas sin horaCuando la hora no importa
df[COLUMNAS]Fija el orden de las columnasÚltima línea antes de escribir
reset_index(drop=True)RenumeraDespués de ordenar
pd.testing.assert_frame_equal(a, b)Compara dos tablasLa comprobación de ida y vuelta
to_csv(f, sep=";", decimal=",")CSV «europeo»Si lo pide el destino

Ponete a prueba

1. ¿Que pasa si escribis un CSV sin index=False?Nivel 1 · Recordar
2. ¿Como se escribe un valor ausente en un CSV, por defecto?Nivel 1 · Recordar
3. Una tabla tiene una columna de pesos (2 decimales) y una de coeficientes (4 decimales). ¿Por que no sirve float_format="%.2f"?Nivel 2 · Predecir la salida
4. ¿Que da round(2.5) en Python?Nivel 2 · Predecir la salida
5. En pandas 3, ¿cual de estas produce la palabra nan en el archivo?Nivel 3 · Elegir entre dos casi correctas
6. ¿Para que sirve la comprobacion de ida y vuelta (escribir, releer, comparar)?Nivel 3 · Elegir entre dos casi correctas
7. Tu archivo y el objetivo tienen las mismas filas y los mismos numeros, y comparar.py igual marca diferencias en una columna de enteros. ¿Que puede estar pasando?
salida["cantidad"] = salida["cantidad"]   # viene con un ausente
salida.to_csv("mi_salida.csv", index=False)
Nivel 4 · Encontrar el defecto

Prácticas de esta lección