Read & write to a CSV file using a list in ASP.NET Core

Published: Monday 3 November 2025

You can read from or write to a CSV in an ASP.NET Core Web API.

Install a NuGet package

First of all, you need to make sure that you have the CSVHelper NuGet package installed. This library will give you the methods needed to read from or write to a CSV file.

Read a CSV file and convert it to a List

With the NuGet package installed, you can read from the CSV file. This is the Products.csv file we will attempt to read from:

"Id","Name"
"1","Watch"
"2","Necklace"
"3","Ring"
"4","Bracelet"
"5","Earrings"
"6","Charm"
"7","Pendant"
"8","Cuff links"
"9","Brooch"
"10","Bangle"

First, we need to make sure that the file exists. If it doesn't, we throw a FileNotFoundException. Otherwise, we create a new StreamReader instance with the full file path and use it to create a new CsvReader instance. With the CsvReader instance, we can call either the GetRecords or GetRecordsAsync methods to get the records from the CSV file and convert it to a List.

// ReadProductCsvDto.cs
public record ReadProductCsvDto(
	int Id,
	string Name
);
// CsvReadHelper.cs
using CsvHelper.Configuration;
using CsvHelper;
using System.Globalization;

public static class CsvReadHelper
{
	public static async Task<IList<T>> ReadCsvFile<T>(
		string fullFilePath)
	{
		if (!File.Exists(fullFilePath))
		{
			throw new FileNotFoundException("Unable to find Product CSV file");
		}

		using var streamReader = new StreamReader(fullFilePath,
			new FileStreamOptions
			{
				Access = FileAccess.Read,
				Mode = FileMode.Open
			});

		using var csvReader = new CsvReader(streamReader,
			new CsvConfiguration(CultureInfo.InvariantCulture));

		var records = new List<T>();
		await foreach (var record in csvReader.GetRecordsAsync<T>())
		{
			records.Add(record);
		}

		return records;
	}
}
// CsvReadController.cs
[Route("api/csv-read")]
[ApiController]
public class CsvReadController : ControllerBase
{
	[HttpGet]
	public async Task<ActionResult<IList<ReadProductCsvDto>>> ReadProducts()
	{
		try
		{
			return Ok(
				await CsvReadHelper.ReadCsvFile<ReadProductCsvDto>(
					$"{Environment.CurrentDirectory}/Files/Products.csv"));
		}
		catch (FileNotFoundException)
		{
			return NotFound();
		}
	}
}

The ReadProducts method in CsvReadController will output the following JSON response:

[
	{
		"id": 1,
		"name": "Watch"
	},
	{
		"id": 2,
		"name": "Necklace"
	},
	{
		"id": 3,
		"name": "Ring"
	},
	{
		"id": 4,
		"name": "Bracelet"
	},
	{
		"id": 5,
		"name": "Earrings"
	},
	{
		"id": 6,
		"name": "Charm"
	},
	{
		"id": 7,
		"name": "Pendant"
	},
	{
		"id": 8,
		"name": "Cuff links"
	},
	{
		"id": 9,
		"name": "Brooch"
	},
	{
		"id": 10,
		"name": "Bangle"
	}
]

What if there is no header?

You might have a CSV file that has no header like this ProductsNoHeader.csv file:

"1","Watch"
"2","Necklace"
"3","Ring"
"4","Bracelet"
"5","Earrings"
"6","Charm"
"7","Pendant"
"8","Cuff links"
"9","Brooch"
"10","Bangle"

To resolve that, we need to add an extra hasHeaderRecord parameter to the ReadCsvFile method in CsvReaderHelper and then use it when we create the CsvConfiguration instance:

// CsvReadHelper.cs
public static class CsvReadHelper
{
	public static async Task<IList<T>> ReadCsvFile<T>(
		...
		bool hasHeaderRecord = true)
	{
		...

		using var csvReader = new CsvReader(streamReader, 
			new CsvConfiguration(CultureInfo.InvariantCulture)
		{
			HasHeaderRecord = hasHeaderRecord
		});

		...
	}
}

Now the ReadCsvFile method looks like this:

// CsvReadHelper.cs
public static class CsvReadHelper
{
	public static async Task<IList<T>> ReadCsvFile<T>(
		string fullFilePath,
		bool hasHeaderRecord = true)
	{
		if (!File.Exists(fullFilePath))
		{
			throw new FileNotFoundException("Unable to find Product CSV file");
		}

		using var streamReader = new StreamReader(fullFilePath,
			new FileStreamOptions
			{
				Access = FileAccess.Read,
				Mode = FileMode.Open
			});

		using var csvReader = new CsvReader(streamReader,
			new CsvConfiguration(CultureInfo.InvariantCulture)
			{
				HasHeaderRecord = hasHeaderRecord
			});

		var records = new List<T>();
		await foreach (var record in csvReader.GetRecordsAsync<T>())
		{
			records.Add(record);
		}

		return records;
	}
}

We can call that method from an API endpoint, making sure that we set the hasHeaderRecord parameter to false.

// CsvReadController.cs
[Route("api/csv-read")]
[ApiController]
public class CsvReadController : ControllerBase
{
    ...

    [HttpGet("no-header")]
    public async Task<ActionResult<IList<ReadProductCsvDto>>> ReadProductsNoHeader()
    {
        try
        {
            return Ok(
                await CsvReadHelper.ReadCsvFile<ReadProductCsvDto>(
                    $"{Environment.CurrentDirectory}/Files/ProductsNoHeader.csv",
                    hasHeaderRecord: false));
        }
        catch (FileNotFoundException)
        {
            return NotFound();
        }
    }
}

This would output the same JSON response as when the header was included.

Read selected properties

It might be the case that we only want to use selected columns from the CSV file into the application. To do that, we can create a new class, set which properties we wish to include and use the Index attribute to specify the column index number from the CSV file.

// ReadProductCsvNameDto.cs
using CsvHelper.Configuration.Attributes;

public class ReadProductCsvNameDto
{
	[Index(1)]
	public string Name { get; init; } = string.Empty;
}
// CsvReadController.cs
[Route("api/csv-read")]
[ApiController]
public class CsvReadController : ControllerBase
{
	...

	[HttpGet("product-name")]
	public async Task<ActionResult<IList<ReadProductCsvNameDto>>> ReadProductsName()
	{
		try
		{
			return Ok(
				await CsvReadHelper.ReadCsvFile<ReadProductCsvNameDto>(
					$"{Environment.CurrentDirectory}/Files/Products.csv"));
		}
		catch (FileNotFoundException)
		{
			return NotFound();
		}
	}
}

When running the ReadProductsName method in the CsvReadController class, the output will only include the name:

[
	{
		"name": "Watch"
	},
	{
		"name": "Necklace"
	},
	{
		"name": "Ring"
	},
	{
		"name": "Bracelet"
	},
	{
		"name": "Earrings"
	},
	{
		"name": "Charm"
	},
	{
		"name": "Pendant"
	},
	{
		"name": "Cuff links"
	},
	{
		"name": "Brooch"
	},
	{
		"name": "Bangle"
	}
]

Different header names

You might find that the header names in the CSV file do not correspond to the property names in the class. This is the case in this ProductsDifferentHeaderNames.csv file:

"Product Id","Product Name"
"1","Watch"
"2","Necklace"
"3","Ring"
"4","Bracelet"
"5","Earrings"
"6","Charm"
"7","Pendant"
"8","Cuff links"
"9","Brooch"
"10","Bangle"

The CSV file header names are Product Id and Product Name, whereas the property names we are going to use are Id and Name. Fortunately, you can use the Name attribute in each property to override the CSV file header name.

using CsvHelper.Configuration.Attributes;

// ReadProductCsvDifferentHeaderNameDto.cs
public class ReadProductCsvDifferentHeaderNameDto
{
    [Name("Product Id")]
    public int Id { get; init; }

    [Name("Product Name")]
    public string Name { get; init; } = string.Empty;
}
// CsvReadController.cs
[Route("api/csv-read")]
[ApiController]
public class CsvReadController : ControllerBase
{
	...

	[HttpGet("different-header-names")]
	public async Task<ActionResult<IList<ReadProductCsvDifferentHeaderNameDto>>> ReadDifferentHeaderNames()
	{
		try
		{
			return Ok(
				await CsvReadHelper.ReadCsvFile<ReadProductCsvDifferentHeaderNameDto>(
					$"{Environment.CurrentDirectory}/Files/ProductsDifferentHeaderNames.csv"));
		}
		catch (FileNotFoundException)
		{
			return NotFound();
		}
	}
}

Read the CSV file by line

If you want to read the CSV file by line, you can do that by either calling the Read or ReadAsync method. If your CSV file has a header row, you need to call the ReadHeader method when it gets to that line:

// CsvReadHelper.cs
public static class CsvReadHelper
{
	...

	public static async Task<IList<ReadProductCsvDto>> ReadProductsCsvFileByLine(
		string fullFilePath)
	{
		if (!File.Exists(fullFilePath))
		{
			throw new FileNotFoundException("Unable to find Product CSV file");
		}

		using var streamReader = new StreamReader(fullFilePath,
			new FileStreamOptions
			{
				Access = FileAccess.Read,
				Mode = FileMode.Open
			});

		using var csvReader = new CsvReader(streamReader,
			new CsvConfiguration(CultureInfo.InvariantCulture));

		var records = new List<ReadProductCsvDto>();

		await csvReader.ReadAsync();
		csvReader.ReadHeader();
		while (await csvReader.ReadAsync())
		{
			records.Add(new ReadProductCsvDto(
				csvReader.GetField<int>("Id"),
				csvReader.GetField("Name")!
			));
		}

		return records;
	}
}
// CsvReadController.cs
[Route("api/csv-read")]
[ApiController]
public class CsvReadController : ControllerBase
{
    ...

    [HttpGet("by-line")]
    public async Task<ActionResult<IList<ReadProductCsvDto>>> ReadByLine()
    {
        try
        {
            return Ok(
                await CsvReadHelper.ReadProductsCsvFileByLine(
                    $"{Environment.CurrentDirectory}/Files/Products.csv"));
        }
        catch (FileNotFoundException)
        {
            return NotFound();
        }
    }
}

Read the CSV file contents

If you just want to read the CSV file contents and return it as part of an API endpoint response, you can do that with the StreamReader instance. Go through each line and add it to a string variable.

// CsvReadHelper.cs
public static class CsvReadHelper
{    
	...

	public static async Task<string> ReadCsvFileContents(string fullFilePath)
	{
		if (!File.Exists(fullFilePath))
		{
			throw new FileNotFoundException("Unable to find Product CSV file");
		}

		var fileContents = string.Empty;
		using var streamReader = new StreamReader(fullFilePath,
			new FileStreamOptions
			{
				Access = FileAccess.Read,
				Mode = FileMode.Open
			});

		string? line;
		while ((line = await streamReader.ReadLineAsync()) != null)
		{
			// Store each line into a variable called file
			if (!string.IsNullOrWhiteSpace(fileContents))
			{
				fileContents += Environment.NewLine;
			}
			fileContents += line;
		}

		return fileContents;
	}
}
// CsvReadController.cs
[Route("api/csv-read")]
[ApiController]
public class CsvReadController : ControllerBase
{
	...

	[HttpGet("products-csv-file-contents")]
	public async Task<IActionResult> ReadProductsCsvFileContents()
	{
		try
		{
			return Content(
				await CsvReadHelper.ReadCsvFileContents(
					$"{Environment.CurrentDirectory}/Files/Products.csv"),
				"text/csv");
		}
		catch (FileNotFoundException)
		{
			return NotFound();
		}
	}
}

Write a CSV file

You can also write to a CSV file. We've created a WriteCsvFile method which includes two parameters:

  • records - A list of records that will be written to the CSV file
  • fullFilePath - The full file path where the CSV file will be written to
using CsvHelper.Configuration;
using CsvHelper;

// CsvWriteHelper.cs
public static class CsvWriteHelper
{
	public static async Task WriteCsvFile<T>(
		IList<T> records,
		string fullFilePath)
	{
		if (File.Exists(fullFilePath))
		{
			throw new FileFoundException(fullFilePath, 
				"The file already exists");
		}

		using var writer = new StreamWriter(fullFilePath);
		using var csvWriter = new CsvWriter(writer,
			new CsvConfiguration(CultureInfo.InvariantCulture));

		await csvWriter.WriteRecordsAsync(records);
	}
}

We've created a FileFoundException class that will throw an exception if the file exists:

// FileFoundException.cs
public class FileFoundException : Exception
{
	public string FilePath { get; }

	public FileFoundException(string filePath, string message)
		: base(message)
	{
		FilePath = filePath;
	}

	public FileFoundException(string filePath, 
		string message, 
		Exception innerException) : base(message, innerException)
	{
		FilePath = filePath;
	}
}

Assuming the file can't be found, it creates a new StreamWriter instance and uses it to create a new CsvWriter instance to write the records. We will then call this instance to write the CSV file.

We will create a list of the WriteCategoryDto class.

// WriteCategoryDto.cs
public record WriteCategoryDto(int Id, string Name);

It will then be used in the WriteCsvFile method in the CsvWriteHelper class to write the CSV file.

// CsvWriteController.cs
[Route("api/csv-write")]
[ApiController]
public class CsvWriteController : ControllerBase
{
	[HttpPost]
	public async Task<IActionResult> WriteCategory()
	{
		try
		{
			var categories = new List<WriteCategoryDto>()
			{
				new(1, "Electronics"),
				new(2, "Clothes"),
				new(3, "Computers")
			};

			await CsvWriteHelper.WriteCsvFile(
				categories,
				$"{Environment.CurrentDirectory}/Files/Category-{DateTime.UtcNow:yyyyMMdd-HHmmss}.csv"
			);
			return NoContent();
		}
		catch (FileFoundException)
		{
			return BadRequest();
		}
	}
}

When we call the WriteCategory method in the CsvWriteController class, it creates a file that is similar to this:

Id,Name
1,Electronics
2,Clothes
3,Computers

Watch the video

Watch the video where we show you how to read from and write to a CSV file.

And when you download the code example, you will be able to try these CSV methods out for yourself.