Export a List to CSV in C# (CsvHelper and StreamWriter Examples)

Data portability is a requirement for almost every business application. Whether you are building an admin dashboard, a reporting tool, or a data migration script, you will eventually need to export a List of objects to CSV in C#.

Export a List to CSV in C# (CsvHelper and StreamWriter Examples)

CSV (Comma-Separated Values) looks like a simple text format, but writing it correctly means handling fields that contain commas, double quotes, or line breaks. Get this wrong and your file will open with shifted columns or corrupted rows.

This guide shows two ways to write a C# list to a CSV file: the CsvHelper library, and a dependency-free approach using StreamWriter. Both follow the quoting rules described in RFC 4180.

The article is written for both beginners and experienced developers. If you are new to CSV, read it from the top. If you only need a specific answer, use the table of contents to jump straight to it, for example customizing headers, streaming large exports, or returning a CSV download from ASP.NET Core.

Quick Answer: The Fastest Way

If you can add a NuGet package, CsvHelper is the most widely used option for reading and writing CSV in .NET. It escapes commas, quotes, and line breaks for you and maps your class properties to column headers automatically.

Code Snippet:

using CsvHelper;
using System.Globalization;
using System.IO;
using System.Text;

public void ExportData(List<Employee> data, string filePath)
{
    // UTF-8 with BOM helps Excel detect the encoding correctly
    using var writer = new StreamWriter(filePath, false, new UTF8Encoding(true));
    using var csv = new CsvWriter(writer, CultureInfo.InvariantCulture);

    csv.WriteRecords(data);
}

What You Will Learn

What Is CSV and Which Rules Matter?

A CSV file is plain text where each line is a record and each value in the line is a field, separated by a delimiter (usually a comma). The first line is often a header row that names the columns.

RFC 4180 documents the common conventions. It is an informational document rather than a strict standard, and real-world tools differ slightly, but its rules are what most CSV readers expect:

  • Each record is on its own line.
  • Fields are separated by commas.
  • A header line is optional but common.
  • A field that contains a comma, a double quote, or a line break must be enclosed in double quotes.
  • A double quote inside a quoted field is written as two double quotes ("").

The last two rules are where hand-written exporters usually fail. They are the reason this article spends so much time on escaping.

Why Exporting to CSV Matters

CSV is one of the most common formats for exchanging tabular data. It is lightweight, human-readable, and opens in Microsoft Excel, Google Sheets, and most database import tools.

Typical reasons to export a list of objects to CSV include:

  • Letting users download reports or table data from a web application.
  • Moving data between systems during a migration.
  • Creating simple backups or audit logs.
  • Feeding data into spreadsheets, BI tools, or import jobs.

Writing the file by hand looks easy at first. A call like string.Join(",", values) works for simple data, but it breaks as soon as a value contains a comma, such as the name "Smith, John".

Without proper quoting, that comma splits the name into two columns and shifts every value after it. To avoid this, a CSV writer must quote special fields and escape any double quotes inside them.

This guide compares two approaches:

  1. CsvHelper: a well-established library that handles escaping and type conversion for you.
  2. Native System.IO: a manual approach with no dependencies, useful when you cannot add packages.

Prerequisites and Setup

To follow along, you need:

  • A current .NET SDK. The examples use modern C# and work on .NET 8 or later. .NET 10 is the current long-term support (LTS) release, while .NET 8 and .NET 9 are supported until 10 November 2026.
  • Visual Studio 2022 or VS Code.
  • For Method 1, a recent CsvHelper release. The examples assume version 29 or later. Configuration through property setters and InjectionOptions both appear in the v29 era.

New projects enable implicit usings by default, so namespaces such as System.Collections.Generic, System.IO, System.Linq, and System.Threading.Tasks are already available. The examples still list the using directives they rely on so they are easy to follow. If your project disables implicit usings, add the missing ones manually.

For the first method, install the CsvHelper NuGet package.

Install via the Package Manager Console (Visual Studio):

Install-Package CsvHelper

Or install via the .NET CLI:

dotnet add package CsvHelper

The Data Model

Throughout this guide we use the following Employee class and sample data. Two of the records are deliberately awkward: one name contains a comma and another contains double quotes.

The string properties are initialized with string.Empty. New projects have nullable reference types enabled, and without an initializer the compiler warns that a non-nullable property is uninitialized.

public class Employee
{
    public int Id { get; set; }
    public string Name { get; set; } = string.Empty;
    public string Title { get; set; } = string.Empty;
    public decimal Salary { get; set; }
}

// Sample data
var employees = new List<Employee>
{
    new Employee { Id = 1, Name = "Nikunj Satasiya", Title = "Developer", Salary = 85000 },
    new Employee { Id = 2, Name = "Smith, John", Title = "Manager", Salary = 95000 }, // Note the comma!
    new Employee { Id = 3, Name = "Bob \"The Builder\"", Title = "Contractor", Salary = 60000 } // Note the quotes!
};

The model is intentionally flat: every property is a simple value. Exports are easiest when each column maps to one simple property. If your real data contains nested objects or collections, project it into a flat class (a DTO) before exporting.

Method 1: Export with CsvHelper

CsvHelper is the option most .NET developers reach for when writing CSV files. It reads your class's public properties and writes them as columns, using the property names as the header row.

Why Use This Method?

  • Automatic escaping: fields containing commas, line breaks, or double quotes are quoted correctly.
  • Type conversion: dates, decimals, and booleans are converted to text using the CultureInfo you pass in.
  • Configurable output: headers, column order, formats, and delimiters can be changed without writing your own formatting code.
  • Less code to maintain: mapping, headers, and escaping rules are handled by the library instead of your own helper methods.

The Implementation

The example below writes the list to a file. Each part has a specific job:

  • StreamWriter writes text to the file on disk. Passing false as the second argument means "overwrite the file if it exists" instead of appending.
  • new UTF8Encoding(true) adds a Byte Order Mark (BOM). Excel uses it to detect UTF-8, so names with accented or non-Latin characters display correctly. A plain StreamWriter without an encoding writes UTF-8 without a BOM.
  • CultureInfo.InvariantCulture makes number formatting the same on every machine, so the output does not depend on the server's regional settings.
  • WriteRecords writes the header row followed by one row per object.
  • The using var declarations dispose both writers at the end of the method. Disposing the CsvWriter flushes buffered data, so the file is complete when the method returns.
using CsvHelper;
using System.Globalization;
using System.IO;
using System.Text;

public static void WriteToCsvWithLibrary(List<Employee> employees, string filePath)
{
    // UTF8Encoding(true) emits the BOM for Excel compatibility
    using var writer = new StreamWriter(filePath, false, new UTF8Encoding(true));
    using var csv = new CsvWriter(writer, CultureInfo.InvariantCulture);

    csv.WriteRecords(employees);
}

Note that WriteRecords with a List<T> assumes the whole list is already in memory. That is fine for small and medium exports. For very large datasets, see the streaming section below.

The Result

For "Smith, John", CsvHelper wraps the value in quotes so the comma stays inside a single column:

Id,Name,Title,Salary
1,Nikunj Satasiya,Developer,85000
2,"Smith, John",Manager,95000
3,"Bob ""The Builder""",Contractor,60000

Note: The internal quotes in Bob's name were escaped by doubling them (""), as RFC 4180 specifies.

Customizing Headers, Column Order, and Formats

By default, the header row uses your property names and columns follow the property order. If you need friendlier column names, a specific column order, or custom formats, create a ClassMap and register it before writing.

A class map keeps this configuration out of your model class, which helps when the same model is used elsewhere in your application.

using CsvHelper.Configuration;

public sealed class EmployeeMap : ClassMap<Employee>
{
    public EmployeeMap()
    {
        Map(m => m.Id).Name("Employee ID");
        Map(m => m.Name).Name("Full Name");
        Map(m => m.Title).Name("Job Title");
        Map(m => m.Salary).Name("Salary");
    }
}

// Register the map on the writer, then write as usual
csv.Context.RegisterClassMap<EmployeeMap>();
csv.WriteRecords(employees);

Columns are written in the order they are mapped. You can also set the order explicitly with Index, for example Map(m => m.Id).Index(0). If you use Index, set it on every mapped member so the order is unambiguous.

Formatting dates and numbers. You can control how a value is converted to text through the type converter options. For example, assuming your model also had a HireDate property of type DateTime:

Map(m => m.HireDate).Name("Hire Date").TypeConverterOption.Format("yyyy-MM-dd");
Map(m => m.Salary).TypeConverterOption.Format("F2"); // two decimal places, e.g. 85000.00

The yyyy-MM-dd format is unambiguous and sorts correctly as text, which makes it a safe choice for exported files. The format strings are standard .NET format strings, and the CultureInfo you passed to the writer still decides symbols such as the decimal separator.

Excluding a column. To leave a property out of the export, mark it as ignored in the map (use this in place of the Salary mapping above, not in addition to it):

Map(m => m.Salary).Ignore();

Alternative: Configure Columns with Attributes

If you prefer to keep the configuration on the model itself, CsvHelper also supports attributes from the CsvHelper.Configuration.Attributes namespace. This is convenient for small projects where the model is used only for exports.

using CsvHelper.Configuration.Attributes;

public class Employee
{
    [Index(0)]
    [Name("Employee ID")]
    public int Id { get; set; }

    [Index(1)]
    [Name("Full Name")]
    public string Name { get; set; } = string.Empty;

    [Ignore]
    public decimal Salary { get; set; }
}

The trade-off is that your model now depends on CsvHelper. A class map keeps the model clean, so it is usually the better choice in larger applications.

Writing Records One at a Time

Sometimes you want more control than WriteRecords gives you, for example to skip certain rows or to write a computed value. In that case, write the header and each record yourself:

csv.WriteHeader<Employee>();
csv.NextRecord();

foreach (var employee in employees)
{
    if (employee.Salary <= 0)
    {
        continue; // skip invalid rows
    }

    csv.WriteRecord(employee);
    csv.NextRecord();
}

NextRecord ends the current line. Forgetting it is a common reason for all values ending up on a single row.

Using a Different Delimiter

Some regional Excel settings expect a semicolon instead of a comma. You can change the delimiter by passing a CsvConfiguration to the writer. Setting HasHeaderRecord to false skips the header row.

using CsvHelper.Configuration;

var config = new CsvConfiguration(CultureInfo.InvariantCulture)
{
    Delimiter = ";",
    HasHeaderRecord = true
};

using var writer = new StreamWriter(filePath, false, new UTF8Encoding(true));
using var csv = new CsvWriter(writer, config);

csv.WriteRecords(employees);

Keep in mind that a file with semicolons is no longer comma-separated, so make sure whoever consumes it expects that delimiter.

Method 2: Export with the Native StreamWriter

Sometimes you cannot add external libraries because of company security policies, or because you are building a small function where every dependency counts. In those cases you can write CSV yourself with StreamWriter, as long as you handle escaping correctly.

The Challenge: Manual Escaping

If you simply write $"{emp.Name},{emp.Title}", the file breaks as soon as a value contains a comma. Every text field needs to be checked and sanitized.

The helper method below follows the RFC 4180 rules. If a value contains a comma, a double quote, or a line break (\r or \n), it wraps the value in double quotes and doubles any quotes already inside it. Null and empty values become an empty field.

The example also passes CultureInfo.InvariantCulture to string.Format. This keeps numbers in a consistent format. In cultures that use a comma as the decimal separator, a decimal such as 85000.5 could otherwise be written as 85000,5, which would add an extra column.

using System.Globalization;
using System.IO;
using System.Text;

public static class NativeCsvExporter
{
    private static readonly char[] SpecialCharacters = { ',', '"', '\r', '\n' };

    public static void WriteToCsvNative(List<Employee> employees, string filePath)
    {
        using var writer = new StreamWriter(filePath, false, new UTF8Encoding(true));

        // 1. Write the header
        writer.WriteLine("Id,Name,Title,Salary");

        // 2. Write one line per employee
        foreach (var emp in employees)
        {
            var line = string.Format(
                CultureInfo.InvariantCulture,
                "{0},{1},{2},{3}",
                emp.Id,
                EscapeCsvValue(emp.Name),
                EscapeCsvValue(emp.Title),
                emp.Salary);

            writer.WriteLine(line);
        }
    }

    private static string EscapeCsvValue(string? value)
    {
        if (string.IsNullOrEmpty(value))
        {
            return string.Empty;
        }

        if (value.IndexOfAny(SpecialCharacters) >= 0)
        {
            // Double any existing quotes, then wrap the whole value in quotes
            return "\"" + value.Replace("\"", "\"\"") + "\"";
        }

        return value;
    }
}

How the Native Version Works

The exporter does three things: it writes a hard-coded header, formats each object into one line, and passes every text field through EscapeCsvValue. The helper only adds quotes when it has to, which keeps the file small and readable. The output is the same as the CsvHelper result shown earlier.

A few details are worth knowing about this version:

  • Only text fields are escaped. Id and Salary are numbers, so they cannot contain commas or quotes when formatted with the invariant culture.
  • The header is hard-coded. If you add or rename a property, you must update both the header and the format string yourself. CsvHelper avoids this maintenance cost.
  • Line endings. WriteLine uses the operating system's newline (\r\n on Windows, \n on Linux and macOS). RFC 4180 specifies CRLF, but most modern tools accept either. If a consumer requires CRLF, set writer.NewLine = "\r\n" before writing.
  • Async support. StreamWriter also offers WriteLineAsync. If you use it, switch to await using so the writer is disposed asynchronously.

CsvHelper vs Native StreamWriter: Which Should You Choose?

Both methods produce valid CSV when used correctly. The difference is how much work you take on yourself.

Comparison of CsvHelper and the native StreamWriter approach
Feature CsvHelper Native StreamWriter
Extra dependency Yes (NuGet package) No
Escaping commas, quotes, line breaks Automatic You write and test the helper
Header row Generated from properties or a class map Written manually
Custom names, order, formats Built in through class maps or attributes Manual code changes
Async and streaming support Yes Possible, but you write it
Maintenance when the model changes Usually little or none Update header and format string
Best for Most applications Restricted environments and small utilities

As a rule of thumb, choose CsvHelper unless a policy or environment prevents you from adding packages. If you go native, test the escaping helper carefully with awkward data.

Asynchronous Export (Async/Await)

In web APIs and other server code, prefer asynchronous file I/O so threads are not blocked while the file is written. CsvHelper provides async methods for this.

The await using declarations make sure the writers are flushed and disposed asynchronously when the method finishes:

public static async Task WriteToCsvAsync(List<Employee> employees, string filePath)
{
    await using var writer = new StreamWriter(filePath, false, new UTF8Encoding(true));
    await using var csv = new CsvWriter(writer, CultureInfo.InvariantCulture);

    await csv.WriteRecordsAsync(employees);
}

Streaming Large Datasets

If you export hundreds of thousands or millions of rows, loading everything into a List<T> first can cause very high memory usage and, in extreme cases, an OutOfMemoryException.

A better approach is to stream the data. With IAsyncEnumerable<T>, records are read and written one at a time, so memory use stays low no matter how many rows you export. CsvHelper's CsvWriter has a WriteRecordsAsync overload that accepts an IAsyncEnumerable<T>.

public static async Task ExportLargeDataAsync(IAsyncEnumerable<Employee> dataStream, string filePath)
{
    await using var writer = new StreamWriter(filePath, false, new UTF8Encoding(true));
    await using var csv = new CsvWriter(writer, CultureInfo.InvariantCulture);

    await csv.WriteRecordsAsync(dataStream);
}

The data source can be anything that produces an IAsyncEnumerable<Employee>. With Entity Framework Core, for example, you can pass a streamed query directly:

// Streams rows from the database instead of loading them all at once
var stream = dbContext.Employees.AsNoTracking().AsAsyncEnumerable();
await ExportLargeDataAsync(stream, "employees.csv");

AsNoTracking avoids tracking every entity in the change tracker, which is unnecessary for a read-only export. Also keep in mind that a streamed query holds a database connection open until the export finishes, so keep the DbContext alive for the whole operation and avoid running other queries on the same context at the same time.

Returning a CSV File from ASP.NET Core

A common real-world task is letting users download the CSV from a web endpoint instead of saving it to disk. The idea is to write the CSV into a MemoryStream and return the bytes as a file result with the text/csv content type.

The writers must be disposed before you read the stream's contents. Disposing flushes any buffered text into the MemoryStream. Calling ToArray() afterwards is safe, because MemoryStream.ToArray() also works on a stream that has already been closed.

using CsvHelper;
using Microsoft.AspNetCore.Mvc;
using System.Globalization;
using System.IO;
using System.Text;

[ApiController]
[Route("api/employees")]
public class EmployeesController : ControllerBase
{
    [HttpGet("export")]
    public async Task<IActionResult> Export()
    {
        List<Employee> employees = await GetEmployeesAsync(); // replace with your data access code

        using var memoryStream = new MemoryStream();

        await using (var writer = new StreamWriter(memoryStream, new UTF8Encoding(true)))
        await using (var csv = new CsvWriter(writer, CultureInfo.InvariantCulture))
        {
            await csv.WriteRecordsAsync(employees);
        }

        return File(memoryStream.ToArray(), "text/csv", "employees.csv");
    }
}

The third argument of File is the suggested download file name. Browsers use it when saving the file.

This approach holds the whole file in memory, so it suits small and medium exports. For very large files, write to a temporary file or stream directly to the response instead of building a byte array.

Verifying Your CSV Output

Reading the exported file back is a quick way to prove that your escaping works, whether the file was written by CsvHelper or by your own code. If the round trip returns the same values, commas and quotes survived.

using CsvHelper;
using System.Globalization;
using System.IO;

using var reader = new StreamReader(filePath);
using var csv = new CsvReader(reader, CultureInfo.InvariantCulture);

List<Employee> imported = csv.GetRecords<Employee>().ToList();

foreach (var employee in imported)
{
    Console.WriteLine($"{employee.Id}: {employee.Name}");
}

With the sample data, the output should be:

1: Nikunj Satasiya
2: Smith, John
3: Bob "The Builder"

This check assumes you exported with the default property names. If you used a class map with custom header names, register the same map on the CsvReader before calling GetRecords. It is also worth opening the file in Excel or Google Sheets at least once, because that is where most users will see it.

Security: CSV Formula Injection

If exported values come from users and the file will be opened in a spreadsheet, a text value that starts with =, +, -, or @ can be interpreted as a formula. This is known as CSV injection or formula injection. Attackers can use it to run formulas on the machine of whoever opens the file.

CsvHelper has a setting for this. From version 29 onward, CsvConfiguration.InjectionOptions controls the behavior. The default is None, which does no protection, so you must turn it on yourself. With InjectionOptions.Escape, CsvHelper escapes a field that starts with a risky character by prefixing it with a single quote.

using CsvHelper.Configuration;

var config = new CsvConfiguration(CultureInfo.InvariantCulture)
{
    InjectionOptions = InjectionOptions.Escape
};

using var csv = new CsvWriter(writer, config);

In the native approach you have to do this yourself. The helper below prefixes risky values with a single quote. Use it only on free-text fields, because legitimate text such as "-5 degrees" will also gain a prefix.

private static string NeutralizeFormula(string value)
{
    if (value.Length > 0 && (value[0] is '=' or '+' or '-' or '@' or '\t' or '\r'))
    {
        return "'" + value;
    }

    return value;
}

// Usage: sanitize first, then apply the normal CSV escaping
EscapeCsvValue(NeutralizeFormula(emp.Name))

This lowers the risk but is not a complete security solution. If the data is untrusted, also consider whether users really need to open it in a spreadsheet application.

Common Mistakes and Things to Watch For

  • Joining strings with commas. string.Join(",", ...) works only when no value contains a comma, quote, or line break. Escape values or use a library.
  • Formatting numbers with the current culture. Always pass CultureInfo.InvariantCulture unless you specifically need a regional format.
  • Garbled characters in Excel. If accented or non-Latin text looks wrong when opening the file in Excel, write the file as UTF-8 with a BOM, as shown in the examples.
  • Regional delimiters in Excel. In some regional settings Excel expects a semicolon instead of a comma. If your users report that everything appears in one column, this is a common cause. See the custom delimiter example above.
  • Leading zeros and long numbers in Excel. When Excel opens a CSV directly, it may convert values such as 00123 to 123 or show long numbers in scientific notation. This is Excel's behavior rather than a problem in the file. If it matters, tell users to import the file as text, or export those columns in a way your consumers expect.
  • Formula injection. Values that begin with =, +, -, or @ can be executed as formulas. See the security section.
  • Loading everything into memory. For very large exports, stream the data instead of building a full list first.
  • Reading the stream too early. When exporting to a MemoryStream, dispose or flush the writers before calling ToArray(), or the output may be incomplete.
  • Forgetting NextRecord. When writing rows manually with CsvHelper, call NextRecord() after each row or everything ends up on one line.
  • Expecting spreadsheet features. CSV is plain text. It cannot store formatting, formulas, multiple sheets, or column widths. If you need those, use a library that writes real Excel (.xlsx) files.

Frequently Asked Questions

How do I export a List of objects to CSV in C# without a library?

Use StreamWriter to write a header line and one line per object, and escape every text field that contains a comma, double quote, or line break. The native example in Method 2 shows a complete implementation.

How do I handle commas inside CSV values?

Wrap the value in double quotes. If the value already contains double quotes, double each one (" becomes ""). CsvHelper does this automatically.

Do I need the BOM when writing CSV?

Only if the file will be opened in Excel and contains non-ASCII characters. Excel uses the BOM to detect UTF-8. Many other tools and APIs work fine without it, and some may treat the BOM as part of the first header name, so omit it when you are not targeting Excel.

Can I export very large lists to CSV?

Yes, but avoid holding all rows in a List<T>. Stream the records with IAsyncEnumerable<T> as shown in the streaming section.

Can I change the column names or order?

Yes. In CsvHelper, create a ClassMap<T> and register it before writing, or use attributes such as Name and Index on the model. In the native approach, change the header line and the format string directly.

How do I let users download the CSV from an ASP.NET Core API?

Write the CSV into a MemoryStream, dispose the writers, and return the bytes with File(bytes, "text/csv", "employees.csv"), as shown in the ASP.NET Core section.

Can I read the CSV file back into a list of objects?

Yes. CsvHelper's CsvReader has a GetRecords<T>() method, shown in the section on verifying your output.

Is CSV the same as an Excel file?

No. CSV is plain text that Excel can open, but it has no formatting, formulas, or multiple sheets. If you need those features, generate an .xlsx file with a dedicated library instead.

Summary

For most projects, CsvHelper is the simpler and safer way to export a list to CSV in C#. It handles escaping, type conversion, headers, custom mapping, and streaming with very little code, which reduces the chance of subtle formatting bugs.

The native StreamWriter approach is still worth knowing when you cannot add dependencies. Just remember to escape special characters, use the invariant culture for numbers, and be deliberate about encoding and line endings.

Whichever method you choose, test your export with awkward data such as commas, quotes, line breaks, and non-English characters, and read the file back to confirm it round-trips. If the data comes from users, decide up front how you will handle formula injection before shipping the feature.

Codingvila provides articles and blogs on web and software development for beginners as well as free Academic projects for final year students in Asp.Net, MVC, C#, Vb.Net, SQL Server, Angular Js, Android, PHP, Java, Python, Desktop Software Application and etc.

If you have any questions, contact us on info.codingvila@gmail.com