Export a DataTable to Excel (.xlsx) in C# Without Office Interop

· unvell team
Export a DataTable to Excel (.xlsx) in C# Without Office Interop

DataTable is still everywhere in .NET: it falls out of SqlCommand, DbDataAdapter, Oracle.ManagedDataAccess, and half the reporting code in any enterprise codebase. The single most common thing teams want to do with one is “give the user this as an Excel file.”

This article walks through how to do that cleanly from C#, without Microsoft.Office.Interop.Excel, in a way that works on a server, in a container, and on Linux.


What we actually want

Given a DataTable like this:

var table = new DataTable("Invoices");
table.Columns.Add("InvoiceNo", typeof(string));
table.Columns.Add("Customer",  typeof(string));
table.Columns.Add("Amount",    typeof(decimal));
table.Columns.Add("DueDate",   typeof(DateTime));

table.Rows.Add("INV-1001", "Acme Co.",   1240.50m, new DateTime(2026, 6, 1));
table.Rows.Add("INV-1002", "Beta LLC",     980.00m, new DateTime(2026, 6, 5));
table.Rows.Add("INV-1003", "Gamma Ltd.", 12450.75m, new DateTime(2026, 6, 15));

…the goal is a real .xlsx file with:

  • A bold header row using the column names
  • Numbers formatted as currency
  • Dates formatted as dates (not “45809”)
  • Auto-sized columns
  • Something the user can open in Excel without a “this file is corrupted” dialog

That’s the bar. Let’s hit it three ways.


Option 1: ClosedXML (InsertTable)

ClosedXML has a built-in InsertTable(DataTable) that does most of the work in one call.

using ClosedXML.Excel;

using var book = new XLWorkbook();
var sheet = book.Worksheets.Add("Invoices");

// One line writes headers + every row.
var range = sheet.Cell("A1").InsertTable(table, "Invoices", createTable: true);

// Number / date formats by column.
sheet.Column("C").Style.NumberFormat.Format = "$#,##0.00";
sheet.Column("D").Style.NumberFormat.Format = "yyyy-mm-dd";

sheet.Columns().AdjustToContents();

book.SaveAs("invoices.xlsx");

createTable: true writes it as an OpenXML table (the thing Excel renders with banded rows and a filter dropdown). Pass false if you just want plain values. The library reads the DataTable.Columns collection for headers and DataRow.ItemArray for values — DBNull is written as an empty cell.

Trade-off: ClosedXML loads the whole workbook into memory. Fine up to a few hundred thousand cells; gets uncomfortable past that.


Option 2: EPPlus (LoadFromDataTable)

EPPlus has the equivalent helper, with slightly different ergonomics:

using OfficeOpenXml;

// EPPlus 5+ requires you to declare a license context.
ExcelPackage.LicenseContext = LicenseContext.NonCommercial;

using var package = new ExcelPackage();
var sheet = package.Workbook.Worksheets.Add("Invoices");

// Returns the cell range that was written.
var range = sheet.Cells["A1"].LoadFromDataTable(table, PrintHeaders: true);

range.AutoFitColumns();
sheet.Cells[2, 3, sheet.Dimension.End.Row, 3].Style.Numberformat.Format = "$#,##0.00";
sheet.Cells[2, 4, sheet.Dimension.End.Row, 4].Style.Numberformat.Format = "yyyy-mm-dd";

package.SaveAs(new FileInfo("invoices.xlsx"));

Two things to know about EPPlus in 2026:

  1. Licensing. EPPlus changed to a Polyform Noncommercial license in version 5. For any commercial use you need a paid license. Plenty of teams missed this and ended up out of compliance — worth checking before you ship.
  2. Streaming write API. For very large tables, use package.Workbook.Worksheets.Add(name) plus LoadFromDataTable against a streamed source. Or skip EPPlus entirely and use the OpenXML OpenXmlWriter directly.

Option 3: ReoGrid (AppendRows + DataSource)

ReoGrid is the option you reach for when the same data also needs to be shown to a user in a WinForms or WPF grid — it’s a real spreadsheet component, not just an I/O library. But it works as a headless .xlsx writer too.

using unvell.ReoGrid;
using unvell.ReoGrid.DataFormat;

var book = ReoGridControl.CreateMemoryWorkbook();
var sheet = book.Worksheets[0];
sheet.Name = "Invoices";

// Write the header row.
sheet["A1"] = new object[] { "InvoiceNo", "Customer", "Amount", "DueDate" };
sheet.Ranges["A1:D1"].Style.Bold = true;

// Append every DataRow as a worksheet row.
foreach (DataRow row in table.Rows)
{
    sheet.AppendRows(1);
    int r = sheet.MaxContentRow;
    for (int c = 0; c < table.Columns.Count; c++)
        sheet[r, c] = row[c];
}

// Column-level formats.
sheet.SetRangeDataFormat("C2:C" + (table.Rows.Count + 1),
    CellDataFormatFlag.Currency,
    new CurrencyDataFormatter.CurrencyFormatArgs { PrefixSymbol = "$", DecimalPlaces = 2 });

sheet.SetRangeDataFormat("D2:D" + (table.Rows.Count + 1),
    CellDataFormatFlag.DateTime,
    new DateTimeDataFormatter.DateTimeFormatArgs { Format = "yyyy-MM-dd" });

book.Save("invoices.xlsx");

If your project already references ReoGrid because you’re rendering a worksheet on screen, you don’t add a second Excel library just to export. The same Worksheet object you bind to the control is the one that writes the .xlsx.

For larger tables, ReoGrid 4.4 added SetRangeData for bulk loading — ~3× faster than per-cell assignment, useful when you’re materializing a 100,000-row DataTable:

var data = new object[table.Rows.Count, table.Columns.Count];
for (int r = 0; r < table.Rows.Count; r++)
    for (int c = 0; c < table.Columns.Count; c++)
        data[r, c] = table.Rows[r][c];

sheet.SetRangeData("A2", data);

Adding a total row

Reports almost always need a footer. The pattern is the same in all three libraries: write a formula in the row below the data, point it at the column range.

// ClosedXML
var lastRow = table.Rows.Count + 1;
sheet.Cell(lastRow + 1, 1).Value = "Total";
sheet.Cell(lastRow + 1, 1).Style.Font.Bold = true;
sheet.Cell(lastRow + 1, 3).FormulaA1 = $"=SUM(C2:C{lastRow})";
sheet.Cell(lastRow + 1, 3).Style.NumberFormat.Format = "$#,##0.00";
// ReoGrid — formula evaluates at save time
int lastRow = table.Rows.Count + 1;
sheet[lastRow, 0] = "Total";
sheet.Ranges[lastRow, 0, 1, 1].Style.Bold = true;
sheet[lastRow, 2] = $"=SUM(C2:C{lastRow})";

ReoGrid evaluates the formula via its built-in formula engine at save time, so the cached value is already on disk when the file lands. ClosedXML stores the formula and lets Excel compute the value on open — call book.RecalculateAllFormulas() first if you need the cached value baked in.


Multiple sheets from a DataSet

If you have a DataSet, you usually want one sheet per DataTable:

// ClosedXML
using var book = new XLWorkbook();
foreach (DataTable t in dataSet.Tables)
{
    var s = book.Worksheets.Add(SanitizeSheetName(t.TableName));
    s.Cell("A1").InsertTable(t);
    s.Columns().AdjustToContents();
}
book.SaveAs("report.xlsx");

static string SanitizeSheetName(string name)
{
    // Excel rules: max 31 chars, no : \ / ? * [ ]
    var safe = new string(name.Select(c => "\\/:?*[]".Contains(c) ? '_' : c).ToArray());
    return safe.Length > 31 ? safe[..31] : safe;
}

The sheet-name sanitization step is the one most code skips and then crashes on the first table name containing a slash. Excel’s rules are strict — 31 chars max, no : \ / ? * [ ], and no duplicates within a workbook.


Streaming the file from ASP.NET Core

The other half of “export to Excel” is usually a controller action that streams the file back to the browser. The pattern that works for all three libraries:

[HttpGet("export/invoices")]
public IActionResult Export()
{
    var table = _repo.GetInvoices();      // your DataTable

    using var stream = new MemoryStream();
    using (var book = new XLWorkbook())
    {
        var sheet = book.Worksheets.Add("Invoices");
        sheet.Cell("A1").InsertTable(table);
        sheet.Columns().AdjustToContents();
        book.SaveAs(stream);
    }

    return File(
        stream.ToArray(),
        "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
        $"invoices-{DateTime.UtcNow:yyyyMMdd}.xlsx");
}

Three things worth noting:

  • The MIME type is application/vnd.openxmlformats-officedocument.spreadsheetml.sheet. Sending application/octet-stream works but Excel may not auto-associate it.
  • using the workbook before ToArray() matters — SaveAs(stream) doesn’t flush until disposal.
  • For files over a few MB, return a FileStreamResult from a file-backed stream instead of buffering with ToArray(). The default ASP.NET Core response buffer will happily hold a 200 MB array in memory otherwise.

Which one to pick

A short decision tree for this specific task (DataTable.xlsx):

  • Headless reporting service, no UI? → ClosedXML. Free, MIT, InsertTable is one line.
  • Already paying for EPPlus, or need its chart/pivot features? → EPPlus. Just be honest about the license.
  • The same app also shows the spreadsheet to the user (WinForms/WPF), or needs formulas evaluated at write time? → ReoGrid. One library for I/O and UI.
  • Files larger than ~500k cells? → Stream with OpenXmlWriter or use ReoGrid’s SetRangeData. Don’t try to keep half a million cells in XLCell objects.

For the “weekly report endpoint that emits a .xlsx” use case, ClosedXML is the boring correct answer. The moment your endpoint grows into “and also let users view/edit it in our app,” reconsider — gluing two libraries together is more code than picking one that does both.


Further reading

Related articles

Try ReoGrid in your own project

The Excel-compatible spreadsheet component for .NET WinForms and WPF. 30-day free trial — no credit card required.