Quick wins, real benchmarks, and when to trade developer joy for raw speed.
#

In a recent project we needed to take form data and inject it into an existing Excel template — replacing placeholder variables and producing filled spreadsheets automatically. We evaluated the available .NET libraries (looking at maintenance, maturity and community support) and found only two relevant contenders for this scenario:

1
2
<PackageReference Include="ClosedXML" Version="0.105.0" />
<PackageReference Include="DocumentFormat.OpenXml" Version="3.3.0" />

We compared both ergonomics (how easy is it to open and manipulate an Excel file?) and performance (measured with BenchmarkDotNet). Below is a pragmatic walkthrough of what we did, the demo code we used, the benchmark results, and a recommendation you can reuse.

TL;DR
#

  • OpenXmlSdk = lower-level, faster, less memory allocation → best for high-throughput, server-side processing.
  • ClosedXML = higher-level, developer-friendly, faster to implement → great for prototyping and templates with formatting.
  • Benchmarks show OpenXmlSdk is ~2.9–3.5Ă— faster and uses ~1/3 of the allocations compared to ClosedXML in our tests.

A quick note about my background
#

I already had some experience with OpenXmlSdk — it’s powerful, but you end up “sliding through rows and columns” more compared to ClosedXML’s niceties. That background helped when we needed to optimize for performance, but it also showed why ClosedXML is so popular for day-to-day work.

What the demo does
#

  1. Ensures a template exists (Assets/Template.xlsx).
  2. Prompts the user for a set of variables (e.g. vehicleRegistration, revenueQ1, status_A, budget_A).
  3. Copies the template into two files and performs token replacement using:
  • a low-level OpenXmlSdk approach (replacing text inside the shared string table), and
  • a high-level ClosedXML approach (iterating used cells and replacing values).

Both demo methods replace tokens like ##Revenue_Q1## etc. with provided values (or defaults).

Demo entry point (RunDemo)
#

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
public static void RunDemo()
{
    // Ensure template exists
    ExcelTemplateGenerator.EnsureTemplateExists();
    Console.WriteLine("=== Excel Replacement Demo ===");
    Console.WriteLine();

    // Collect all variables
    Console.WriteLine("Please enter value for 'Vehicle Registration':");
    var vehicleRegistration = Console.ReadLine();
    Console.WriteLine("Please enter value for 'Dashboard':");
    var dashboard = Console.ReadLine();
    Console.WriteLine("Please enter value for 'Defect Description':");
    var defectDescription = Console.ReadLine();
    Console.WriteLine("Please enter value for 'Revenue Q1':");
    var revenueQ1 = Console.ReadLine();
    Console.WriteLine("Please enter value for 'Profit Q1':");
    var profitQ1 = Console.ReadLine();
    Console.WriteLine("Please enter value for 'Status A':");
    var statusA = Console.ReadLine();
    Console.WriteLine("Please enter value for 'Budget A':");
    var budgetA = Console.ReadLine();

    var fileId = Guid.NewGuid();
    // Demo OpenXML SDK
    DemoOpenXmlSdk(fileId, vehicleRegistration, dashboard, defectDescription, revenueQ1, profitQ1, statusA, budgetA);
    // Demo ClosedXML
    DemoClosedXml(fileId, vehicleRegistration, dashboard, defectDescription, revenueQ1, profitQ1, statusA, budgetA);
    Console.WriteLine();
    Console.WriteLine("Demo completed!");
    Console.WriteLine($"Files generated:");
    Console.WriteLine($"- {fileId}_OpenXML.xlsx");
    Console.WriteLine($"- {fileId}_ClosedXML.xlsx");
}

Open XML SDK approach (low-level, precise)
#

This approach opens the XLSX package, iterates the shared string table and replaces placeholders directly in SharedStringItem s:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
private static void DemoOpenXmlSdk(Guid fileId, string? vehicleRegistration, string? dashboard, string? defectDescription, string? revenueQ1, string? profitQ1, string? statusA, string? budgetA)
{
    var fileName = $"{fileId}_OpenXML.xlsx";
    File.Copy("Assets/Template.xlsx", fileName, true);
    Console.WriteLine($"Created a new file with ID: {fileName}");
    Console.WriteLine("Using Open XML SDK to replace variables in the Excel file...");
    Stopwatch stopwatch = Stopwatch.StartNew();
    using (FileStream fileStream = new(fileName, FileMode.Open, FileAccess.ReadWrite))
    using (SpreadsheetDocument document = SpreadsheetDocument.Open(fileStream, true))
    {
        WorkbookPart? workbookPart = document.WorkbookPart;
        WorksheetPart? worksheetPart = workbookPart?.WorksheetParts.First();
        SheetData? sheetData = worksheetPart?.Worksheet.GetFirstChild<SheetData>();
        if (sheetData == null)
        {
            Console.WriteLine("No sheet data found in the workbook.");
            return;
        }
        foreach (Row row in sheetData.Elements<Row>())
        {
            foreach (Cell cell in row.Elements<Cell>())
            {
                if (cell.CellValue is not null && cell.DataType != null && cell.DataType.Value == CellValues.SharedString)
                {
                    int id = int.Parse(cell.CellValue.InnerText);
                    SharedStringItem cellTextItem = workbookPart!.SharedStringTablePart!.SharedStringTable.Elements<SharedStringItem>().ElementAt(id);
                    string text = cellTextItem.InnerText;
                    text = text.Replace("##VehicleRegistration##", vehicleRegistration ?? "DefaultVehicle")
                        .Replace("##Dashboard##", dashboard ?? "DefaultDashboard")
                        .Replace("##DefectDescription##", defectDescription ?? "DefaultDefect")
                        .Replace("##Date##", DateTime.Now.ToString("MM/dd/yyyy"))
                        .Replace("##Revenue_Q1##", revenueQ1 ?? "450,000")
                        .Replace("##Profit_Q1##", profitQ1 ?? "85,000")
                        .Replace("##Costs_Q1##", "365,000")
                        .Replace("##Margin_Q1##", "18.9")
                        .Replace("##Revenue_Q2##", "520,000")
                        .Replace("##Profit_Q2##", "95,000")
                        .Replace("##Costs_Q2##", "425,000")
                        .Replace("##Margin_Q2##", "18.3")
                        .Replace("##Revenue_Q3##", "580,000")
                        .Replace("##Profit_Q3##", "110,000")
                        .Replace("##Costs_Q3##", "470,000")
                        .Replace("##Margin_Q3##", "19.0")
                        .Replace("##Revenue_Q4##", "620,000")
                        .Replace("##Profit_Q4##", "125,000")
                        .Replace("##Costs_Q4##", "495,000")
                        .Replace("##Margin_Q4##", "20.2")
                        .Replace("##Status_A##", statusA ?? "Completed")
                        .Replace("##Budget_A##", budgetA ?? "75,000")
                        .Replace("##Status_B##", "In Progress")
                        .Replace("##Budget_B##", "120,000")
                        .Replace("##Status_C##", "Planned")
                        .Replace("##Budget_C##", "200,000");
                    cellTextItem.GetFirstChild<Text>()!.Text = text;
                }
            }
        }
        worksheetPart?.Worksheet.Save();
    }
    stopwatch.Stop();
    Console.WriteLine($"Replacement completed in {stopwatch.ElapsedMilliseconds} ms using Open XML SDK!");
}

Why choose it: efficient, minimal allocations, fine-grained control.
Downside: more verbose and more code to maintain.


ClosedXML approach (high-level, ergonomic)
#

This approach opens the workbook with ClosedXML, iterates used cells and replaces values:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
private static void DemoClosedXml(Guid fileId, string? vehicleRegistration, string? dashboard, string? defectDescription, string? revenueQ1, string? profitQ1, string? statusA, string? budgetA)
{
    var fileName = $"{fileId}_ClosedXML.xlsx";
    File.Copy("Assets/Template.xlsx", fileName, true);
    Console.WriteLine($"Created a new file with ID: {fileName}");
    Console.WriteLine("Using ClosedXML to replace variables in the Excel file...");
    Stopwatch stopwatch = Stopwatch.StartNew();
    using (var workbook = new XLWorkbook(fileName))
    {
        var worksheet = workbook.Worksheet(1);
        foreach (var cell in worksheet.CellsUsed())
        {
            if (cell.HasFormula) continue;
            var value = cell.GetString();
            value = value.Replace("##VehicleRegistration##", vehicleRegistration ?? "DefaultVehicle")
                         .Replace("##Dashboard##", dashboard ?? "DefaultDashboard")
                         .Replace("##DefectDescription##", defectDescription ?? "DefaultDefect")
                         .Replace("##Date##", DateTime.Now.ToString("MM/dd/yyyy"))
                         .Replace("##Revenue_Q1##", revenueQ1 ?? "450,000")
                         .Replace("##Profit_Q1##", profitQ1 ?? "85,000")
                         .Replace("##Costs_Q1##", "365,000")
                         .Replace("##Margin_Q1##", "18.9")
                         .Replace("##Revenue_Q2##", "520,000")
                         .Replace("##Profit_Q2##", "95,000")
                         .Replace("##Costs_Q2##", "425,000")
                         .Replace("##Margin_Q2##", "18.3")
                         .Replace("##Revenue_Q3##", "580,000")
                         .Replace("##Profit_Q3##", "110,000")
                         .Replace("##Costs_Q3##", "470,000")
                         .Replace("##Margin_Q3##", "19.0")
                         .Replace("##Revenue_Q4##", "620,000")
                         .Replace("##Profit_Q4##", "125,000")
                         .Replace("##Costs_Q4##", "495,000")
                         .Replace("##Margin_Q4##", "20.2")
                         .Replace("##Status_A##", statusA ?? "Completed")
                         .Replace("##Budget_A##", budgetA ?? "75,000")
                         .Replace("##Status_B##", "In Progress")
                         .Replace("##Budget_B##", "120,000")
                         .Replace("##Status_C##", "Planned")
                         .Replace("##Budget_C##", "200,000");
            cell.Value = value;
        }
        workbook.Save();
    }
    stopwatch.Stop();
    Console.WriteLine($"Replacement completed in {stopwatch.ElapsedMilliseconds} ms using ClosedXML!");
}

Why choose it: readable code, fast to implement, great for formatting and formulas.
Downside: higher memory allocations and lower throughput for bulk processing.


Benchmarks (real runs)
#

We ran BenchmarkDotNet for both methods. Here are the trimmed results relevant to decision-making:

MethodEnvironmentMeanGen0AllocatedAlloc Ratio
OpenXmlSdkJob-YETQRT3.101 ms46.8750317.09 KB1.00 âś“
ClosedXmlJob-YETQRT4.733 ms171.87501098.7 KB3.46Ă— slower
OpenXmlSdk.NET 9.01.057 ms50.7813316.62 KB1.00 âś“
ClosedXml.NET 9.03.264 ms171.87501099.18 KB3.47Ă— slower

Conclusion from benchmarks: OpenXmlSdk is notably faster and uses far fewer allocations. For many-file generation scenarios, that difference matters.


âś… Recommendation (practical)
#

Choose Open XML SDK when:

  • You need the best performance and minimal allocations.
  • You generate many files concurrently or work at scale.
  • You don’t mind more detailed code and extra boilerplate.

Choose ClosedXML when:

  • Developer speed matters and you want readable, maintainable code.
  • Templates include complicated formatting / ranges you want to manipulate easily.
  • Throughput demands are moderate.