Leer y escribir CSV en Kotlin: comillas, data class y validación

Los CSV están en todas partes: exportaciones del banco, reportes del punto de venta, hojas de Excel guardadas como texto. Leerlos parece cosa de un split, hasta que un campo trae una coma dentro. Aquí verás un lector pequeño que respeta las comillas, cómo convertir cada fila en una data class y cómo avisar de las filas malas sin que el programa se caiga.

Por qué no basta con split(«,»)

Un CSV parece trivial: una línea por fila y comas entre los campos. Este es el archivo que vamos a leer, con las ventas de una tienda de café:

fecha,producto,cantidad,precio
2026-11-02,Café de Chiapas 500 g,3,189.50
2026-11-02,"Taza ""Buenos días"", blanca",2,149.00
2026-11-03,Café de Chiapas 500 g,1,189.50
2026-11-04,"Molino manual, acero",1,1299.99
2026-11-05,Filtros de papel,4,45.00

Las filas 3 y 5 tienen un campo con una coma dentro, y por eso va entre comillas. La 3 además lleva comillas dentro del texto, que en CSV se escriben dobles (""). Así lo parte un split(","):

import java.io.File

fun main() {
    for (linea in File("ventas.csv").readLines()) {
        val campos = linea.split(",")
        println("${campos.size} campos: $campos")
    }
}
salida4 campos: [fecha, producto, cantidad, precio]
4 campos: [2026-11-02, Café de Chiapas 500 g, 3, 189.50]
5 campos: [2026-11-02, "Taza ""Buenos días"",  blanca", 2, 149.00]
4 campos: [2026-11-03, Café de Chiapas 500 g, 1, 189.50]
5 campos: [2026-11-04, "Molino manual,  acero", 1, 1299.99]
4 campos: [2026-11-05, Filtros de papel, 4, 45.00]

Dos filas salen con cinco campos en lugar de cuatro y con las comillas pegadas al texto. Si después lees la cantidad en la posición 2, en esas filas leerías un trozo del nombre del producto.

Un lector que entiende las comillas

La regla del formato (está en el RFC 4180) es corta: un campo entre comillas puede tener comas y saltos de línea, y una comilla dentro se escribe doble. Esta función recorre la línea carácter a carácter con un when y la aplica. Con ella, cada fila se convierte en una data class:

import java.io.File
import java.math.BigDecimal
import java.time.LocalDate
import java.util.Locale

fun partir(linea: String): List<String> {
    val campos = mutableListOf<String>()
    val actual = StringBuilder()
    var entreComillas = false
    var i = 0
    while (i < linea.length) {
        val c = linea[i]
        when {
            entreComillas && c == '"' && linea.getOrNull(i + 1) == '"' -> {
                actual.append('"')   // "" dentro de comillas es una comilla
                i++
            }
            c == '"' -> entreComillas = !entreComillas
            c == ',' && !entreComillas -> {
                campos += actual.toString()
                actual.clear()
            }
            else -> actual.append(c)
        }
        i++
    }
    campos += actual.toString()
    return campos
}

data class Venta(val fecha: LocalDate, val producto: String, val cantidad: Int, val precio: BigDecimal) {
    val total: BigDecimal get() = precio * cantidad.toBigDecimal()
}

fun dinero(x: BigDecimal) = "$" + "%,.2f".format(Locale.US, x) + " MXN"

fun main() {
    val ventas = File("ventas.csv").readLines()
        .drop(1)                      // la cabecera
        .filter { it.isNotBlank() }
        .map { linea ->
            val (fecha, producto, cantidad, precio) = partir(linea)
            Venta(LocalDate.parse(fecha), producto, cantidad.toInt(), precio.toBigDecimal())
        }

    for (v in ventas) {
        println("${v.fecha}  ${v.cantidad} x ${v.producto}  ${dinero(v.total)}")
    }

    println("\nPor producto:")
    ventas.groupBy { it.producto }
        .mapValues { (_, lista) -> lista.sumOf { it.total } }
        .entries.sortedByDescending { it.value }
        .forEach { (producto, total) -> println("  ${producto.padEnd(28)} ${dinero(total)}") }
    println("Total: ${dinero(ventas.sumOf { it.total })}")
}
salida2026-11-02  3 x Café de Chiapas 500 g  $568.50 MXN
2026-11-02  2 x Taza "Buenos días", blanca  $298.00 MXN
2026-11-03  1 x Café de Chiapas 500 g  $189.50 MXN
2026-11-04  1 x Molino manual, acero  $1,299.99 MXN
2026-11-05  4 x Filtros de papel  $180.00 MXN

Por producto:
  Molino manual, acero         $1,299.99 MXN
  Café de Chiapas 500 g        $758.00 MXN
  Taza "Buenos días", blanca   $298.00 MXN
  Filtros de papel             $180.00 MXN
Total: $2,535.99 MXN

Algunas cosas que hace el código y conviene copiar:

  • El dinero en BigDecimal, nunca en Double: "189.50".toBigDecimal() guarda exactamente 189.50, y multiplicar o sumar no acumula errores de redondeo. sumOf acepta BigDecimal directamente.
  • La desestructuración val (fecha, producto, cantidad, precio) = partir(linea) funciona con cualquier lista: toma los cuatro primeros elementos.
  • El formato con Locale.US: "%,.2f" pone la coma de miles y el punto decimal que se usan en México. Sin indicar el Locale, saldría con los del sistema donde se ejecute, que puede ser otro.
  • groupBy + sumOf agrupan y suman en dos líneas; lo explica la lección de lambdas.
Esta función no admite un salto de línea dentro de un campo entre comillas, porque lee línea a línea. Si tus archivos los pueden traer (descripciones largas exportadas de un sistema), usa una biblioteca como Apache Commons CSV o kotlin-csv, que leen el archivo como un flujo de caracteres.

El archivo que llega mal

En la vida real, alguien escribe «dos» en una cantidad o pone el 31 de noviembre. Con el mismo código de antes, la primera fila mala tira el programa entero:

fun main() {
    val ventas = File("ventas-con-errores.csv").readLines().drop(1).map { linea ->
        val (fecha, producto, cantidad, precio) = partir(linea)
        Venta(LocalDate.parse(fecha), producto, cantidad.toInt(), precio.toBigDecimal())
    }
    println(ventas.size)
}
salidaException in thread "main" java.lang.NumberFormatException: For input string: "dos"

Para un archivo que viene de fuera, lo útil es validar cada fila, decir cuál falló y por qué, y seguir con las demás. toIntOrNull() y toBigDecimalOrNull() devuelven null en lugar de lanzar, y require lanza IllegalArgumentException con tu mensaje si la condición no se cumple:

fun leerVenta(campos: List<String>): Venta {
    require(campos.size == 4) { "se esperaban 4 campos y hay ${campos.size}" }
    val (fecha, producto, cantidad, precio) = campos
    return Venta(
        LocalDate.parse(fecha),
        producto,
        cantidad.toIntOrNull() ?: throw IllegalArgumentException("cantidad no válida «$cantidad»"),
        precio.toBigDecimalOrNull() ?: throw IllegalArgumentException("precio no válido «$precio»"),
    )
}

fun main() {
    val buenas = mutableListOf<Venta>()
    File("ventas-con-errores.csv").readLines().drop(1).forEachIndexed { i, linea ->
        val numero = i + 2           // la 1 es la cabecera
        try {
            buenas += leerVenta(partir(linea))
        } catch (e: Exception) {
            println("línea $numero: ${e.message}")
        }
    }
    println("Leídas bien: ${buenas.size}, total ${dinero(buenas.sumOf { it.total })}")
}
salidalínea 3: cantidad no válida «dos»
línea 4: Text '2026-11-31' could not be parsed: Invalid date 'NOVEMBER 31'
línea 5: se esperaban 4 campos y hay 2
Leídas bien: 2, total $866.50 MXN

El mensaje de la fecha es el de java.time, en inglés; el resto son nuestros. Numerar las líneas como las ve el usuario (contando la cabecera) le ahorra contar a mano cuando abra el archivo para corregirlo.

Escribir un CSV

Al escribir hay que aplicar la regla al revés: si el texto tiene una coma, una comilla o un salto de línea, va entre comillas y con las comillas duplicadas:

import java.io.File

fun campo(texto: String): String =
    if (texto.any { it == ',' || it == '"' || it == '\n' }) "\"" + texto.replace("\"", "\"\"") + "\""
    else texto

fun fila(vararg valores: Any): String = valores.joinToString(",") { campo(it.toString()) }

fun main() {
    File("resumen.csv").bufferedWriter().use { salida ->
        salida.write(fila("producto", "piezas", "total"))
        salida.newLine()
        salida.write(fila("Café de Chiapas 500 g", 4, "758.00"))
        salida.newLine()
        salida.write(fila("Molino manual, acero", 1, "1299.99"))
        salida.newLine()
        salida.write(fila("Taza \"Buenos días\", blanca", 2, "298.00"))
        salida.newLine()
    }
    print(File("resumen.csv").readText())
}
salidaproducto,piezas,total
Café de Chiapas 500 g,4,758.00
"Molino manual, acero",1,1299.99
"Taza ""Buenos días"", blanca",2,298.00
Excel y los acentos. Si al abrir el CSV en Excel salen mal las letras con acento, es que lo leyó como si no fuera UTF-8. Escribir primero el carácter \uFEFF (la marca de orden de bytes, BOM) al principio del archivo suele bastar para que Excel lo reconozca.

Si vienes de Java: lo que cambia

La lógica es idéntica en Java; lo que ahorra Kotlin es la ceremonia: la data class en lugar de una clase con constructor, equals y toString (o un record), la desestructuración y el agrupado con groupBy. Compáralo con leer y escribir CSV en Java.

Todo el código de esta página se compiló con Kotlin 2.4.21 y se ejecutó en la JVM de Temurin 25.0.5 en un contenedor limpio y sin red antes de publicarla; las salidas y los mensajes del compilador están copiados de esa ejecución.