DataTable は今でも .NET のあちこちに登場します。SqlCommandDbDataAdapterOracle.ManagedDataAccess の戻り値として、また業務系コードベースのレポーティングまわりの大半で見かけます。そして、ユーザーから最もよく要望されるのは決まって 「これを Excel ファイルとして出力してほしい」 です。

本記事では、Microsoft.Office.Interop.Excel に頼らず、サーバー上でもコンテナ内でも Linux でも動く形で、C# からこれをクリーンに実現する方法を順を追って解説します。


ゴールを明確にする

たとえば、以下のような DataTable があるとします。

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));

…これを次のような実物の .xlsx ファイルに出力したいとします。

  • 列名を使った太字のヘッダー行
  • 通貨書式で表示される数値
  • 日付として表示される日付列(45809 のような連番ではない)
  • 列幅が内容に合わせて自動調整される
  • ユーザーが Excel で開いたときに「ファイルが破損しています」と言われない

これが目標です。3 種類のライブラリで実現してみましょう。


選択肢 1: ClosedXML(InsertTable)

ClosedXML には InsertTable(DataTable) メソッドが組み込まれており、ほとんどの作業を 1 行で済ませられます。

using ClosedXML.Excel;

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

// この 1 行でヘッダーと全行を書き込む。
var range = sheet.Cell("A1").InsertTable(table, "Invoices", createTable: true);

// 列ごとの数値・日付書式。
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 を指定すると、OpenXML の テーブル として書き出されます(Excel が縞模様の行とフィルタのドロップダウン付きで描画するアレです)。プレーンな値だけが欲しい場合は false を渡してください。ライブラリは DataTable.Columns をヘッダーとして読み取り、DataRow.ItemArray を値として書き込みます。DBNull は空セルとして書き出されます。

トレードオフ: ClosedXML はワークブック全体をメモリにロードします。数十万セル程度までは問題ありませんが、それを超えると厳しくなってきます。


選択肢 2: EPPlus(LoadFromDataTable)

EPPlus にも同等のヘルパーがあり、API は少し異なります。

using OfficeOpenXml;

// EPPlus 5 以降ではライセンスコンテキストの宣言が必須。
ExcelPackage.LicenseContext = LicenseContext.NonCommercial;

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

// 書き込んだセル範囲が返される。
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"));

2026 年時点で EPPlus について押さえておくべきことが 2 つあります。

  1. ライセンス。EPPlus はバージョン 5 から Polyform Noncommercial ライセンス に変更されました。商用利用には有償ライセンスが必要です。これを見落として無自覚にライセンス違反になっているチームは少なくありません — 製品にバンドルする前に必ず確認してください。
  2. ストリーミング書き込み API。非常に大きなテーブルを書く場合は、ストリームをソースに LoadFromDataTable を使うか、EPPlus をやめて OpenXML の OpenXmlWriter を直接叩く方が無難です。

選択肢 3: ReoGrid(AppendRows + DataSource)

ReoGrid は、同じデータを WinForms や WPF のグリッドに表示する必要もある場合に選びたい選択肢です — 単なる I/O ライブラリではなく、実物のスプレッドシートコンポーネントです。ただし、ヘッドレスな .xlsx ライターとしても問題なく機能します。

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

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

// ヘッダー行を書き込む。
sheet["A1"] = new object[] { "InvoiceNo", "Customer", "Amount", "DueDate" };
sheet.Ranges["A1:D1"].Style.Bold = true;

// 各 DataRow をワークシートの行として追加する。
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];
}

// 列単位の書式設定。
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");

すでに画面上にワークシートを表示する目的でプロジェクトが ReoGrid を参照しているなら、エクスポートのためだけに 2 つ目の Excel ライブラリを追加する必要はありません。コントロールにバインドしているのと同じ Worksheet オブジェクトが、そのまま .xlsx への書き出しに使えます。

行数の多いテーブルを扱う場合、ReoGrid 4.4 で追加された SetRangeData を使うと一括ロードが可能です — セル単位の代入と比べて約 3 倍高速で、10 万行の 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);

合計行の追加

レポートには合計行が付き物です。書き方はどのライブラリでも同じで、データの直下に数式を書き込み、対象の列範囲を指し示すだけです。

// ClosedXML
var lastRow = table.Rows.Count + 1;
sheet.Cell(lastRow + 1, 1).Value = "合計";
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 — 数式は保存時に評価される
int lastRow = table.Rows.Count + 1;
sheet[lastRow, 0] = "合計";
sheet.Ranges[lastRow, 0, 1, 1].Style.Bold = true;
sheet[lastRow, 2] = $"=SUM(C2:C{lastRow})";

ReoGrid は内蔵の数式エンジンで保存時に数式を評価するため、ファイルがディスクに書き出される時点でキャッシュ済みの値も含まれています。一方、ClosedXML は数式だけを保存し、Excel が開いたタイミングで値を計算します — キャッシュ済みの値もファイルに含めたい場合は、保存前に book.RecalculateAllFormulas() を呼んでください。


DataSet から複数シート出力

DataSet を扱う場合、通常は DataTable ごとに 1 シートを割り当てます。

// 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 のルール: 最大 31 文字、: \ / ? * [ ] を含まないこと
    var safe = new string(name.Select(c => "\\/:?*[]".Contains(c) ? '_' : c).ToArray());
    return safe.Length > 31 ? safe[..31] : safe;
}

シート名のサニタイズはコードでよく抜け落ちる部分で、テーブル名にスラッシュが含まれた瞬間に最初のクラッシュが起こります。Excel のルールは厳格です — 最大 31 文字、: \ / ? * [ ] 禁止、同一ワークブック内での重複も不可です。


ASP.NET Core からのファイルストリーミング

「Excel エクスポート」のもう半分は、たいていの場合、ブラウザにファイルを返すコントローラーアクションです。3 つのライブラリすべてに通用するパターンは次の通りです。

[HttpGet("export/invoices")]
public IActionResult Export()
{
    var table = _repo.GetInvoices();      // 任意の 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");
}

押さえておくべき点が 3 つあります。

  • MIME タイプは application/vnd.openxmlformats-officedocument.spreadsheetml.sheet です。application/octet-stream でも動きますが、Excel と自動的に関連付けされない場合があります。
  • ToArray() を呼ぶ 前に ワークブックを using で破棄することが重要です — SaveAs(stream) は破棄まで実際にはフラッシュされません。
  • 数 MB を超えるファイルでは、ToArray() でメモリにバッファリングするのではなく、ファイルベースのストリームから FileStreamResult を返してください。ASP.NET Core のデフォルトのレスポンスバッファは、200 MB の配列をメモリ上にそのまま保持してしまいます。

どれを選ぶべきか

DataTable.xlsx というこの特定タスクについて、選択の指針を簡潔にまとめると次の通りです。

  • UI のないヘッドレスなレポート生成サービス? → ClosedXML。無料、MIT ライセンス、InsertTable 1 行で完結。
  • すでに EPPlus を契約済み、もしくはチャート・ピボット機能が必要? → EPPlus。ただしライセンスについては正直に確認しておくこと。
  • 同じアプリでスプレッドシートをユーザーに表示する(WinForms/WPF)、または書き込み時に数式評価が必要? → ReoGrid。I/O と UI が 1 つのライブラリで済む。
  • 50 万セルを超える大きなファイル?OpenXmlWriter でストリーミング、もしくは ReoGrid の SetRangeData を使う。50 万セルを XLCell オブジェクトで抱え込もうとしてはいけません。

「週次レポートのエンドポイントが .xlsx を出力する」というユースケースなら、ClosedXML が無難で正解です。ただし、そのエンドポイントが「ついでにユーザーに表示・編集させたい」と成長し始めた瞬間に再考の余地が出てきます — 2 つのライブラリを糊付けするより、両方こなせるものを 1 つ選ぶ方がコード量が少なく済みます。


関連記事