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.
También te puede interesar
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 enDouble:"189.50".toBigDecimal()guarda exactamente 189.50, y multiplicar o sumar no acumula errores de redondeo.sumOfaceptaBigDecimaldirectamente. - 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 elLocale, saldría con los del sistema donde se ejecute, que puede ser otro. groupBy+sumOfagrupan y suman en dos líneas; lo explica la lección de lambdas.
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
\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.