Leer y escribir CSV en C#: comillas, coma decimal y CsvHelper

Un CSV parece el formato más simple del mundo: valores separados por comas. Hasta que un producto se llama «Café, molido» o el ordenador está configurado en español y escribe los decimales con coma. Aquí está cómo leerlos y escribirlos sin esas sorpresas.

Escribir un CSV

.NET no trae un lector ni un escritor de CSV, pero escribirlo es sencillo si se respetan dos reglas del formato (la RFC 4180): un campo que lleva una coma, comillas o un salto de línea va entre comillas, y las comillas de dentro se escriben dobles. La función Campo se encarga de eso:

using System.Globalization;

var ventas = new List<Venta>
{
    new(new DateOnly(2026, 10, 1), "Café, molido", 3, 4.50m),
    new(new DateOnly(2026, 10, 1), "Té verde", 5, 3.20m),
    new(new DateOnly(2026, 10, 2), "Cacao \"puro\"", 2, 6.75m),
};

using (var w = new StreamWriter("ventas.csv"))
{
    w.WriteLine("fecha,producto,unidades,precio");
    foreach (var v in ventas)
    {
        w.WriteLine(string.Join(",",
            v.Fecha.ToString("yyyy-MM-dd"),
            Campo(v.Producto),
            v.Unidades,
            v.Precio.ToString(CultureInfo.InvariantCulture)));
    }
}
Console.Write(File.ReadAllText("ventas.csv"));

// Entre comillas si lleva coma, comillas o salto de línea; las comillas, duplicadas.
static string Campo(string s) =>
    s.IndexOfAny([',', '"', '\n']) >= 0 ? "\"" + s.Replace("\"", "\"\"") + "\"" : s;

record Venta(DateOnly Fecha, string Producto, int Unidades, decimal Precio);
salidafecha,producto,unidades,precio
2026-10-01,"Café, molido",3,4.50
2026-10-01,Té verde,5,3.20
2026-10-02,"Cacao ""puro""",2,6.75

El precio se escribe con CultureInfo.InvariantCulture para que salga con punto. Sin ella, en un sistema en español saldría 4,50, y esa coma partiría el campo en dos.

Por qué Split no basta

La tentación es leer cada línea y partirla por las comas:

foreach (var linea in File.ReadLines("ventas.csv").Skip(1))
{
    string[] campos = linea.Split(',');
    Console.WriteLine($"{campos.Length} campos: {string.Join(" | ", campos)}");
}
salida5 campos: 2026-10-01 | "Café |  molido" | 3 | 4.50
4 campos: 2026-10-01 | Té verde | 5 | 3.20
4 campos: 2026-10-02 | "Cacao ""puro""" | 2 | 6.75

La primera fila sale con cinco campos en vez de cuatro, porque «Café, molido» lleva una coma dentro, y la tercera conserva las comillas del formato. Con datos que no controlas, Split(',') acaba fallando.

Leer un CSV respetando las comillas

Basta con recorrer la línea carácter a carácter y recordar si estamos dentro de unas comillas:

using System.Globalization;
using System.Text;

var ventas = File.ReadLines("ventas.csv")
    .Skip(1)
    .Select(Partir)
    .Select(c => new Venta(
        DateOnly.ParseExact(c[0], "yyyy-MM-dd"),
        c[1],
        int.Parse(c[2]),
        decimal.Parse(c[3], CultureInfo.InvariantCulture)))
    .ToList();

foreach (var v in ventas)
{
    Console.WriteLine($"{v.Fecha:dd/MM}  {v.Producto,-14} {v.Unidades} x {v.Precio}");
}
Console.WriteLine($"Total: {ventas.Sum(v => v.Unidades * v.Precio)}");

// Parte una línea respetando las comillas: "a,b" es un solo campo y "" es una comilla.
static List<string> Partir(string linea)
{
    var campos = new List<string>();
    var actual = new StringBuilder();
    bool entreComillas = false;
    for (int i = 0; i < linea.Length; i++)
    {
        char c = linea[i];
        if (entreComillas)
        {
            if (c == '"' && i + 1 < linea.Length && linea[i + 1] == '"') { actual.Append('"'); i++; }
            else if (c == '"') entreComillas = false;
            else actual.Append(c);
        }
        else if (c == '"') entreComillas = true;
        else if (c == ',') { campos.Add(actual.ToString()); actual.Clear(); }
        else actual.Append(c);
    }
    campos.Add(actual.ToString());
    return campos;
}

record Venta(DateOnly Fecha, string Producto, int Unidades, decimal Precio);
salida01/10  Café, molido   3 x 4.50
01/10  Té verde       5 x 3.20
02/10  Cacao "puro"   2 x 6.75
Total: 43.00

Cada línea se convierte en un record con sus tipos, y a partir de ahí todo es LINQ: aquí, la suma de unidades por precio. Este lector no admite campos con saltos de línea dentro (porque lee línea a línea), que son raros pero legales en CSV. Si te pueden llegar, usa una biblioteca.

La coma decimal

using System.Globalization;

decimal precio = 4.50m;
var es = new CultureInfo("es-ES");

Console.WriteLine(precio.ToString(es));
Console.WriteLine(precio.ToString(CultureInfo.InvariantCulture));
Console.WriteLine(decimal.Parse("4.50", es));
Console.WriteLine(decimal.Parse("4.50", CultureInfo.InvariantCulture));
salida4,50
4.50
450
4.50
El idioma del sistema cambia los números. Para la cultura española, 4,50 es el número y el punto separa los miles, así que decimal.Parse("4.50", es) no da error: da 450. Si tu programa lee un CSV con decimal.Parse sin indicar la cultura, funcionará en tu servidor en inglés y multiplicará los precios por cien en el portátil de alguien en Madrid. En archivos y formatos de intercambio, usa siempre InvariantCulture.

Con una biblioteca: CsvHelper

Para CSV de verdad (campos con saltos de línea, cabeceras con otros nombres, millones de filas), la biblioteca más usada es CsvHelper. En un archivo suelto se añade con la directiva #:package; en un proyecto, con dotnet add package CsvHelper:

#:package CsvHelper@33.1.0
using System.Globalization;
using CsvHelper;
using CsvHelper.Configuration;

var config = new CsvConfiguration(CultureInfo.InvariantCulture)
{
    PrepareHeaderForMatch = a => a.Header.ToLowerInvariant(),
};
using var lector = new StreamReader("ventas.csv");
using var csv = new CsvReader(lector, config);

foreach (var v in csv.GetRecords<Venta>())
{
    Console.WriteLine($"{v.Producto}: {v.Unidades * v.Precio}");
}

class Venta
{
    public DateOnly Fecha { get; set; }
    public string Producto { get; set; } = "";
    public int Unidades { get; set; }
    public decimal Precio { get; set; }
}
salidaCafé, molido: 13.50
Té verde: 16.00
Cacao "puro": 13.50

GetRecords empareja las columnas con las propiedades por su nombre, convierte los tipos (también DateOnly y decimal, con la cultura que le indiques) y devuelve los registros de uno en uno, sin cargar el archivo entero. Como la cabecera está en minúsculas y las propiedades no, PrepareHeaderForMatch pasa los dos nombres a minúsculas antes de compararlos.

No llames a tu archivo como el paquete. Si este programa se llama csvhelper.cs, el proyecto que crea dotnet run por debajo se llama igual que la biblioteca y la restauración falla con error NU1108: Ciclo detectado. Nos pasó al preparar esta página: el archivo se llama con_biblioteca.cs.

Todo el código de esta página se compiló con el .NET SDK 10.0.403 (C# 14) y se ejecutó con .NET 10.0.14 en un contenedor limpio antes de publicarla; las salidas y los mensajes del compilador están copiados de esa ejecución.