Updated on
Writing a CSV file in C# takes two objects and one call: a StreamWriter opens the file, a CsvWriter from the CsvHelper package wraps it, and WriteRecords() turns a collection of objects into rows.
That is the whole operation for a plain class. Most of this article is control over the result: which columns appear, what the headers say, how dates are formatted, and what happens when a value contains a comma.
VIDEO: Working With CSV Files in C#.
Creation of a New Project and a Person Class
As writing to a CSV file doesn’t involve any web technologies, we will create the simplest kind of application, a Console App in C#. Then we add the CsvHelper NuGet package to it with dotnet add package CsvHelper.
After the project creation, let’s create a Person class in a new Person.cs file:
public class Person
{
public int Id { get; set; }
public string Name { get; set; }
public bool IsLiving { get; set; }
}
In Program.cs, let’s now make a list of Person objects which we will soon write to our CSV file:
var myPersonObjects = new List<Person>()
{
new Person { Id = 1, IsLiving = true, Name = "John" },
new Person { Id = 2, IsLiving = true, Name = "Steve" },
new Person { Id = 3, IsLiving = true, Name = "James" }
};
How Do We Write a CSV File in C#?
CsvHelper is the de facto standard library for writing CSV in .NET, and the whole operation is two objects and one call. A StreamWriter opens the file, a CsvWriter wraps it, and WriteRecords() turns a collection into rows.
The CultureInfo we hand the writer decides how numbers and dates are rendered, and it also decides the delimiter. CultureInfo.InvariantCulture writes commas. new CultureInfo("fr-FR") writes semicolons, because a semicolon is the list separator in France.
Property names become the header row and property order becomes the column order, so a plain class needs no configuration at all. A relative path puts the file in the program’s working directory.
The using blocks are what complete the file. The StreamWriter buffers what it writes and flushes on dispose, and disposing the CsvWriter disposes the StreamWriter too. With neither disposed, a small file comes out empty and a large one comes out cut short, and nothing in the file says why.
At the top of Program.cs, we add using CsvHelper; and using System.Globalization;, and then write our list:
using (var writer = new StreamWriter("filePersons.csv"))
using (var csv = new CsvWriter(writer, CultureInfo.InvariantCulture))
{
csv.WriteRecords(myPersonObjects);
}
This is all to it: We first make a StreamWriter which helps us to write things in the first place. You can look at the Files, StreamWriter and StreamReader article to read more about the StreamWriter class and the reason for using the using blocks. We then use this to construct our CsvWriter which is coming from the CsvHelper Nuget package.
The StreamWriter.Dispose reference on Microsoft Learn describes what disposing the writer at the end of its using block does: “Causes any buffered data to be written to the underlying stream, releases the unmanaged resources used by the StreamWriter, and optionally the managed resources.”
If we run this program, a CSV file named filePersons.csv will appear in our program’s working directory. Let’s open this file (with Notepad++ for instance) and see how it looks like:
How Do We Change Column Names and Order in a CSV File?
Attributes on the model class control the output and keep the shape of the CSV next to the data it describes. They live in the CsvHelper.Configuration.Attributes namespace.
[Name("Identifier")] sets the header text for one property. [Index(0)] sets its column position, counted from zero. Index every property once we index any of them: the ones we skip take the index after the highest one above them, and ties usually keep declaration order, so an [Index(0)] on the last of three properties lands it in the second column.
[Format("yyyy-MM-dd")] hands a format string to the value, [Ignore] leaves a property out of the file, and [Constant("EU")] writes the same literal in every row.
Dropping the header row is a writer setting: new CsvConfiguration(CultureInfo.InvariantCulture) { HasHeaderRecord = false }, passed to the CsvWriter in place of the culture. The same switch exists as a class-level [HasHeaderRecord(false)], which only works through a configuration built with the class’s attributes, like CsvConfiguration.FromAttributes<T>(CultureInfo.InvariantCulture).
When the class cannot be annotated, say because it comes from another library, a ClassMap<T> expresses the same mapping from outside it and attaches through csv.Context.RegisterClassMap<PersonMap>().
By annotating the properties in our Person class, we can change the outputted header name or the ordering, which works by annotating the properties by the Name or by the Index annotations:
using CsvHelper.Configuration.Attributes;
public class Person
{
[Name("Identifier")]
[Index(0)]
public int Id { get; set; }
[Index(2)]
public string Name { get; set; }
[Index(1)]
public bool IsLiving { get; set; }
}
By running the program again, our CSV file will change – both the ordering as well as the name of the Id property has changed:
Identifier,IsLiving,Name 1,True,John 2,True,Steve 3,True,James
Of course, we can also decide to omit the headers altogether. In that case, we need to pass in a CsvConfiguration object, from the CsvHelper.Configuration namespace, while constructing our CsvWriter. Within this CsvConfiguration object, we set the HasHeaderRecord property to false:
var configPersons = new CsvConfiguration(CultureInfo.InvariantCulture)
{
HasHeaderRecord = false
};
using (var writer = new StreamWriter("filePersons.csv"))
using (var csv = new CsvWriter(writer, configPersons))
{
csv.WriteRecords(myPersonObjects);
}
When we run the program again, we can see the headers are no longer there.
When we can’t put attributes on the class, the same mapping fits in a ClassMap of its own, in a new PersonMap.cs file:
using CsvHelper.Configuration;
public sealed class PersonMap : ClassMap<Person>
{
public PersonMap()
{
Map(p => p.Id).Name("Identifier").Index(0);
Map(p => p.IsLiving).Index(1);
Map(p => p.Name).Index(2);
}
}
Then we register it on the writer’s context before we write the records:
using var writer = new StreamWriter("filePersonsMapped.csv");
using var csv = new CsvWriter(writer, CultureInfo.InvariantCulture);
csv.Context.RegisterClassMap<PersonMap>();
csv.WriteRecords(myPersonObjects);
The file comes out exactly as the attribute version wrote it: Identifier first, then IsLiving, then Name.
These are the attributes that change what CsvWriter writes, with an example of each:
| Attribute | What it changes in the output | Example |
|---|---|---|
[Name("Identifier")] | The header text for that column | header reads Identifier instead of Id |
[Index(0)] | The column position, counted from zero | first column, once every property has an index |
[Format("yyyy-MM-dd")] | The format string applied to the value | 2006-03-05 instead of 03/05/2006 00:00:00 |
[Ignore] | Leaves the property out of the file | the column does not appear at all |
[Constant("EU")] | Writes the same literal in every row | the row's own value is not used |
[NullValues("N/A")] | The text written when the value is null | N/A instead of an empty field |
[BooleanTrueValues("yes")] / [BooleanFalseValues("no")] | The text written for true and false | yes instead of True |
[CultureInfo("en-US")] | The culture used for that one property | with [Format("C")], writes $1,234.50 |
[Delimiter(";")] (on the class) | The field separator for the whole file | applies only through a configuration built with the class's attributes, such as CsvConfiguration.FromAttributes<T>(culture) |
[HasHeaderRecord(false)] (on the class) | Whether the header row is written | same caveat as [Delimiter] |
The last two rows are class-level attributes, and a CsvWriter built from a CultureInfo ignores them. They take effect only when we build the configuration from the class, for example with CsvConfiguration.FromAttributes<Person>(CultureInfo.InvariantCulture), and pass that to the CsvWriter.
How Do We Append Rows to an Existing CSV File?
We have to pay attention that every time we run our program, the old CSV file is overwritten. Sometimes we would like that data to be appended at the end of a file, instead of overwriting existing data. CsvHelper doesn’t provide a method to do this, as opening/writing to a file is not the responsibility of CsvHelper. We can achieve this by using a FileStream and then construct our StreamWriter by using that FileStream. Of course, we shouldn’t write the headers (again) if we are appending:
var configPersons = new CsvConfiguration(CultureInfo.InvariantCulture)
{
HasHeaderRecord = false
};
using (var stream = File.Open("filePersons.csv", FileMode.Append))
using (var writer = new StreamWriter(stream))
using (var csv = new CsvWriter(writer, configPersons))
{
csv.WriteRecords(myPersonObjects);
}
Let’s run our project to see the output. We have to make sure the file is not open anywhere as this will throw an exception:
1,True,John 2,True,Steve 3,True,James 1,True,John 2,True,Steve 3,True,James
Appending to an existing file is tricky though, as the ordering might have changed when we append, or we might have added new properties. We should make sure to keep this into account when appending. We cover the difference between appending to a file and overwriting it in a separate article.
How Do We Format Dates When Writing a CSV File?
Let’s now add a new property to our Person class:
public DateTime DateOfBirth { get; set; }
And now let’s add a code to write this data to a new CSV file:
var myPersonObjects = new List<Person>()
{
new Person { Id = 1, IsLiving = true, Name = "John", DateOfBirth = Convert.ToDateTime("03/05/2006") },
new Person { Id = 2, IsLiving = true, Name = "Steve", DateOfBirth = Convert.ToDateTime("03/09/1998") },
new Person { Id = 3, IsLiving = true, Name = "James", DateOfBirth = Convert.ToDateTime("03/08/1994") }
};
using (var writer = new StreamWriter("filePersonsWithDoB.csv"))
using (var csv = new CsvWriter(writer, CultureInfo.InvariantCulture))
{
csv.WriteRecords(myPersonObjects);
}
How does it get written when we write this? Let’s run it and see:
Identifier,IsLiving,Name,DateOfBirth 1,True,John,03/05/2006 00:00:00 2,True,Steve,03/09/1998 00:00:00 3,True,James,03/08/1994 00:00:00
Hmm, not exactly what we had in mind actually, as we are not interested in the exact time, but rather are interested in the date, in the yyyy-MM-dd format. Luckily, this is done easily as well using annotations:
[Format("yyyy-MM-dd")]
public DateTime DateOfBirth { get; set; }
We can run it again, to see the change:
Identifier,IsLiving,Name,DateOfBirth 1,True,John,2006-03-05 2,True,Steve,1998-03-09 3,True,James,1994-03-08
CsvHelper also writes DateOnly and TimeOnly properties, and a DateOnly column carries no time component at all, which is usually what a date-of-birth column wants.
How Does CsvHelper Escape Commas, Quotes, and Line Breaks?
A CSV field that contains the delimiter, a double quote, or a line break has to be quoted, and CsvHelper does it without being asked. This is the reason to use a library instead of joining strings with commas.
Doe, John is written as "Doe, John". An embedded double quote is doubled, so He said "hi" becomes "He said ""hi""". A value spanning two lines is wrapped in quotes and keeps its line break inside them, which a parser reads back as one field.
Values that need no quoting are not quoted, so the file stays readable. The ShouldQuote function on the configuration overrides that decision per field.
By default, the library does not guard against CSV injection. A field starting with = is a formula when the file is opened in Excel, so whenever the data came from users, we set InjectionOptions to Escape, which puts an apostrophe in front of such a field.
Let’s give the example a two-property Contact class of its own, in a new Contact.cs file:
public class Contact
{
public int Id { get; set; }
public string Name { get; set; }
}
Then we write three contacts whose names would each break the file if we were building a comma-separated string by hand:
var trickyContacts = new List<Contact>()
{
new Contact { Id = 1, Name = "Doe, John" },
new Contact { Id = 2, Name = "He said \"hi\"" },
new Contact { Id = 3, Name = "line1\nline2" }
};
using (var writer = new StreamWriter("fileContacts.csv"))
using (var csv = new CsvWriter(writer, CultureInfo.InvariantCulture))
{
csv.WriteRecords(trickyContacts);
}
Here is the file it writes:
Id,Name 1,"Doe, John" 2,"He said ""hi""" 3,"line1 line2"
Before CsvHelper 29.0.0, the same switch was a SanitizeForInjection flag, so older code samples that set it no longer compile. With InjectionOptions.Escape, a field such as =1+1 is written as "'=1+1".
How Do We Write a CSV File Asynchronously in C#?
WriteRecordsAsync() is the asynchronous counterpart of WriteRecords(), and it matters as soon as the CSV is produced on a request thread rather than in a console application.
await using replaces using on both the StreamWriter and the CsvWriter. The flush at the end of the block is itself file I/O, and a plain using would run it synchronously and block the thread.
The method takes an IEnumerable<T> and also an IAsyncEnumerable<T>, so records streamed out of a database go straight into the file without a list in between. Each overload also accepts an optional CancellationToken, so an ASP.NET Core endpoint can pass HttpContext.RequestAborted and stop writing once the client disconnects.
There is no gain on a short in-memory list. The win is a large file, a slow disk, or an ASP.NET Core endpoint returning a CSV download, where the thread is released while the bytes go out.
Here is our first write again, made asynchronous:
await using var writer = new StreamWriter("filePersons.csv");
await using var csv = new CsvWriter(writer, CultureInfo.InvariantCulture);
await csv.WriteRecordsAsync(myPersonObjects);
The file it writes is byte for byte the one the synchronous WriteRecords() call writes for the same list.
Conclusion
Writing CSV in C# comes down to a StreamWriter, a CsvWriter and a call to WriteRecords(). Attributes on the model class decide the header text, the column order and the value format, and a ClassMap does the same job when the class is not ours to annotate. And here is how to read the same file back: reading data from a CSV file in C#.
CsvHelper also handles the escaping for us: it quotes every field that contains a comma, a quote or a line break, and doubles the quotes inside it. If another program needs the same data as JSON instead, see writing the same data as JSON.
Until the next one.
All the best.
Tested with .NET 10.0.10 (SDK 10.0.302) and CsvHelper 33.1.0.

