Compare ClosedXML, EPPlus, and ExcelDataReader with examples, pitfalls, and security notes.
Spreadsheet files still move the business world. If you landed here searching c# read excel file or c# read xlsx file, you probably need to ingest .xlsx (or legacy .xls) data into a .NET service, console tool, or background job—without installing Excel on the server.
This guide compares practical approaches, shows a minimal modern example, and covers pitfalls that waste afternoons.
Choose the right tool for the job
.NET offers several paths; the best choice depends on file format, deployment constraints, and whether you need write access.
| Approach | Best for | Notes |
|---|---|---|
| ClosedXML | Read/write .xlsx, friendly API | Pure managed, no Excel install. Popular for business apps. |
| EPPlus | High-performance .xlsx | Licensing changed for commercial use—verify current EPPlus license terms for your org. |
| ExcelDataReader | Read-only, including .xls | Lightweight reader; pair with ExcelDataReader.DataSet for ADO.NET-style tables. |
| Open XML SDK | Low-level control | Verbose; use when you must manipulate Open XML directly. |
| COM interop | Desktop automation on Windows with Excel installed | Avoid on servers; fragile and requires Office. |
For most new server-side projects that only touch .xlsx, ClosedXML or EPPlus are the defaults developers reach for. For mixed legacy .xls uploads, ExcelDataReader still earns its place.
Example: read rows with ClosedXML
Install the NuGet package:
dotnet add package ClosedXML
Minimal reader that treats the first row as headers:
using ClosedXML.Excel;
public static List<Dictionary<string, string>> ReadSheet(string path, string sheetName = "Sheet1")
{
using var workbook = new XLWorkbook(path);
var worksheet = workbook.Worksheet(sheetName);
var rows = worksheet.RangeUsed().RowsUsed().ToList();
if (rows.Count == 0) return new();
var headerCells = rows.First().Cells().Select(c => c.GetString().Trim()).ToList();
var results = new List<Dictionary<string, string>>();
foreach (var row in rows.Skip(1))
{
var dict = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
var cells = row.Cells(1, headerCells.Count).ToList();
for (int i = 0; i < headerCells.Count; i++)
{
var key = headerCells[i];
if (string.IsNullOrEmpty(key)) continue;
dict[key] = cells[i].GetFormattedString().Trim();
}
results.Add(dict);
}
return results;
}
GetFormattedString() respects Excel display formatting—useful for dates and currency. For raw typed values, switch to GetDateTime(), GetDouble(), or TryGetValue patterns and handle blank cells explicitly.
Example: streaming read with ExcelDataReader
When files are large or you only need forward-only reading:
dotnet add package ExcelDataReader
dotnet add package ExcelDataReader.DataSet
Register code pages once at startup (required for some encodings on .NET Core):
System.Text.Encoding.RegisterProvider(
System.Text.CodePagesEncodingProvider.Instance);
using ExcelDataReader;
public static IEnumerable<string[]> ReadRows(string path)
{
using var stream = File.Open(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite);
using var reader = ExcelReaderFactory.CreateReader(stream);
while (reader.Read())
{
var values = new string[reader.FieldCount];
for (int i = 0; i < reader.FieldCount; i++)
{
values[i] = reader.GetValue(i)?.ToString() ?? string.Empty;
}
yield return values;
}
}
FileShare.ReadWrite lets you read while another process still has the file open—handy for exports dropped into a watch folder.
Async and ASP.NET Core uploads
Controller endpoints should not block thread pool threads on big files. Pattern:
- Save upload to a temp path or memory stream (with size limits).
- Process on a background queue if parsing exceeds a few seconds.
- Validate content type and extension—magic-byte check beats trusting the filename alone.
[HttpPost("import")]
[RequestSizeLimit(20_000_000)]
public async Task<IActionResult> Import(IFormFile file, CancellationToken cancellationToken)
{
if (file is null || file.Length == 0) return BadRequest("No file.");
var temp = Path.Combine(Path.GetTempPath(), $"{Guid.NewGuid()}.xlsx");
await using (var fs = File.Create(temp))
{
await file.CopyToAsync(fs, cancellationToken);
}
try
{
var rows = ReadSheet(temp);
return Ok(new { count = rows.Count });
}
finally
{
File.Delete(temp);
}
}
Add virus scanning or sandboxing if uploads come from untrusted users.
Data types Excel lies about
Excel cells are not strongly typed until you interpret them:
- Dates are OLE automation dates as doubles—convert carefully across cultures.
- Numbers may arrive as doubles while IDs look numeric—read IDs as strings to preserve leading zeros.
- Formulas might need cached values versus recalculated results; libraries differ in whether they evaluate formulas.
Document assumptions in your API contract so downstream services do not mis-parse "01234" as 1234.
Performance and memory
Loading an entire workbook into a DataSet is convenient but dangerous for 200 MB files. Prefer:
- Streaming readers for row-by-row processing.
- Batch database inserts (SqlBulkCopy, COPY for PostgreSQL).
- Rejecting files above a configurable row/column threshold early.
Profile memory in staging with production-like files, not three-row samples.
Security considerations
Spreadsheets are a common malware vector (macros in .xlsm). For .xlsx:
- Disable macro-enabled formats if you do not need them.
- Run parsers in isolated workers with least privilege.
- Log import metadata, not full cell contents, when GDPR or HIPAA applies.
Never execute embedded objects or external links from uploaded sheets.
Testing strategy
Keep golden files in source control:
headers_only.xlsxunicode_names.xlsxempty_sheet.xlsxweird_dates.xlsx
Assert column counts, null handling, and error messages. Snapshot tests on JSON output catch accidental breaking changes when upgrading NuGet packages.
Writing files back
If you also export Excel, ClosedXML makes styling and autofilters straightforward:
using var wb = new XLWorkbook();
var ws = wb.AddWorksheet("Report");
ws.Cell(1, 1).InsertTable(records);
wb.SaveAs("report.xlsx");
Large exports might switch to CSV for speed when Excel formatting is unnecessary.
FAQ
Can I read Excel on Linux in .NET?
Yes—ClosedXML, EPPlus, and ExcelDataReader are managed and cross-platform. COM interop is Windows-only.
Which library is fastest?
Benchmark with your file shapes; EPPlus often leads on bulk writes, while streaming readers win on memory for huge inputs.
How do I read a specific column by name?
Normalize header names (trim, case-insensitive dictionary) and reject duplicate headers before processing rows.
What about Google Sheets?
That is an API integration, not file parsing—export to .xlsx or use Sheets API separately.
Mapping rows to strongly typed models
Dictionary-based parsing is flexible for exploratory scripts; production pipelines usually map to records or classes. A simple pattern with ClosedXML:
public sealed record InvoiceRow(string InvoiceId, DateOnly DueDate, decimal Amount);
public static InvoiceRow? TryParseRow(IXLRow row, IReadOnlyDictionary<string, int> col)
{
var id = row.Cell(col["InvoiceId"]).GetString().Trim();
if (string.IsNullOrEmpty(id)) return null;
if (!DateOnly.TryParse(row.Cell(col["DueDate"]).GetString(), out var due))
return null;
if (!row.Cell(col["Amount"]).TryGetValue(out double amt))
return null;
return new InvoiceRow(id, due, (decimal)amt);
}
Build the column index once from the header row and fail fast when required headers are missing—finance spreadsheets love renaming columns without telling engineering.
Handling multiple worksheets
Users upload files where data lives on "Q3" instead of "Sheet1". Enumerate worksheets and either require a name parameter or pick the first non-empty sheet:
foreach (var sheet in workbook.Worksheets)
{
if (sheet.RangeUsed() is null) continue;
// process sheet.Name
}
Log which sheet was imported in your audit trail; debugging "wrong totals" often means the wrong tab was read.
Observability for import jobs
Treat spreadsheet imports like any ETL job:
- Emit row counts, reject counts, and duration metrics.
- Store the first N validation errors with row numbers users can fix in Excel.
- Use idempotent imports (natural keys) so retries do not duplicate records.
When something fails at row 40,000, operators need a line number—not a generic "parse error."
Reading Excel in C# is a solved problem with mature libraries; most production bugs come from typing assumptions, huge files, and unvalidated uploads rather than missing APIs. Pick a library, pin the version, and invest tests in the weird rows finance teams invent.
Comments
Loading comments…