ZetCode

C# DataFrame

last modified October 18, 2026

In this article we work with a DataFrame in C#. The DataFrame class is located in the Microsoft.Data.Analysis namespace.

A DataFrame is a data structure that organizes data into a 2-dimensional table of rows and columns, much like a spreadsheet or a database table. Each column has a name and a type, and all values in a column are of that type. DataFrames are popular in data analysis, and the idea comes from R and Python's pandas.

$ dotnet add package Microsoft.Data.Analysis

We add the library to the project. The examples use version 0.23.

Note: the library is still in a pre-1.0 state: its API changes between versions, and parts of it are less polished than pandas. It is a good fit for loading, inspecting, and transforming tabular data for ML.NET; for heavier analysis there are alternatives such as Deedle or a database.
products.csv
id,product_name,category,unit_price,units_in_stock
1,Chai,Beverages,18.0,39
2,Chang,Beverages,19.0,17
3,Aniseed Syrup,Condiments,10.0,13
4,Chef Anton's Cajun Seasoning,Condiments,22.0,53
5,Chef Anton's Gumbo Mix,Condiments,21.35,0
6,Grandma's Boysenberry Spread,Condiments,25.0,120
7,Uncle Bob's Organic Dried Pears,Produce,30.0,15
8,Northwoods Cranberry Sauce,Condiments,40.0,6
9,Mishi Kobe Niku,Meat/Poultry,97.0,29
10,Ikura,Seafood,31.0,31
11,Queso Cabrales,Dairy Products,21.0,22
12,Queso Manchego La Pastora,Dairy Products,38.0,86
13,Konbu,Seafood,6.0,24
14,Tofu,Produce,23.25,35
15,Genen Shouyu,Condiments,15.5,39
16,Pavlova,Confections,17.45,29
17,Alice Mutton,Meat/Poultry,39.0,0
18,Carnarvon Tigers,Seafood,62.5,42
19,Teatime Chocolate Biscuits,Confections,9.2,25
20,Sir Rodney's Marmalade,Confections,81.0,40
21,Sir Rodney's Scones,Confections,10.0,3
22,Gustaf's Knäckebröd,Grains/Cereals,21.0,104
23,Tunnbröd,Grains/Cereals,9.0,61
24,Guaraná Fantástica,Beverages,4.5,20
25,NuNuCa Nuß-Nougat-Creme,Confections,14.0,76
26,Gumbär Gummibärchen,Confections,31.23,15
27,Schoggi Schokolade,Confections,43.9,49
28,Rössle Sauerkraut,Produce,45.6,26
29,Thüringer Rostbratwurst,Meat/Poultry,123.79,0
30,Nord-Ost Matjeshering,Seafood,25.89,10
31,Gorgonzola Telino,Dairy Products,12.5,0
32,Mascarpone Fabioli,Dairy Products,32.0,9
33,Geitost,Dairy Products,2.5,112
34,Sasquatch Ale,Beverages,14.0,111
35,Steeleye Stout,Beverages,18.0,20
36,Inlagd Sill,Seafood,19.0,112
37,Gravad lax,Seafood,26.0,11
38,Côte de Blaye,Beverages,263.5,17
39,Chartreuse verte,Beverages,18.0,69
40,Boston Crab Meat,Seafood,18.4,123
41,Jack'England Clam Chowder,Seafood,9.65,85
42,Singaporean Hokkien Fried Mee,Grains/Cereals,14.0,26
43,Ipoh Coffee,Beverages,46.0,17
44,Gula Malacca,Condiments,19.45,27
45,Rogede sild,Seafood,9.5,5
46,Spegesild,Seafood,12.0,95
47,Zaanse koeken,Confections,9.5,36
48,Chocolade,Confections,12.75,15
49,Maxilaku,Confections,20.0,10
50,Valkoinen suklaa,Confections,16.25,65
51,Manjimup Dried Apples,Produce,53.0,20
52,Filo Mix,Grains/Cereals,7.0,38
53,Perth Pasties,Meat/Poultry,32.8,0
54,Tourtière,Meat/Poultry,7.45,21
55,Pâté chinois,Meat/Poultry,24.0,115
56,Gnocchi di nonna Alice,Grains/Cereals,38.0,21
57,Ravioli Angelo,Grains/Cereals,19.5,36
58,Escargots de Bourgogne,Seafood,13.25,62
59,Raclette Courdavault,Dairy Products,55.0,79
60,Camembert Pierrot,Dairy Products,34.0,19
61,Sirop d'érable,Condiments,28.5,113
62,Tarte au sucre,Confections,49.3,17
63,Vegie-spread,Condiments,43.9,24
64,Wimmers gute Semmelknödel,Grains/Cereals,33.25,22
65,Louisiana Fiery Hot Pepper Sauce,Condiments,21.05,76
66,Louisiana Hot Spiced Okra,Condiments,17.0,4
67,Laughing Lumberjack Lager,Beverages,14.0,52
68,Scottish Longbreads,Confections,12.5,6
69,Gudbrandsdalsost,Dairy Products,36.0,26
70,Outback Lager,Beverages,15.0,15
71,Flotemysost,Dairy Products,21.5,26
72,Mozzarella di Giovanni,Dairy Products,34.8,14
73,Röd Kaviar,Seafood,15.0,101
74,Longlife Tofu,Produce,10.0,4
75,Rhönbräu Klosterbier,Beverages,7.75,125
76,Lakkalikööri,Beverages,18.0,57
77,Original Frankfurter grüne Soße,Condiments,13.0,32

In the examples, we use this CSV file. It must be copied to the output directory of the project, which is done with an item in the project file: <None Update="products.csv" CopyToOutputDirectory="PreserveNewest" />; with dotnet run, the working directory is the project directory, so the relative path works as well.

C# DataFrame simple example

The following is a simple DataFrame example.

Program.cs
using Microsoft.Data.Analysis;

var df = DataFrame.LoadCsv("products.csv");

Console.WriteLine($"{df.Rows.Count} rows, {df.Columns.Count} columns");
Console.WriteLine(df.Info());
Console.WriteLine(df.Description());

We load data from the products.csv file and call the Info and Description methods on the dataframe.

var df = DataFrame.LoadCsv("products.csv");

The data is loaded from the CSV file with the DataFrame.LoadCsv method. The first line is taken as the header with the column names. The Rows.Count and Columns.Count properties give the size of the table.

Console.WriteLine(df.Info());
Console.WriteLine(df.Description());

The Info method shows the column types and the number of values. Description gives the basic statistics of the numeric columns: the maximum, the minimum, and the mean.

$ dotnet run
77 rows, 5 columns
Info           id             product_name   category       unit_price     units_in_stock
DataType       System.Single  System.String  System.String  System.Single  System.Single
Length (excluding null values)77             77             77             77             77

Description    id             unit_price     units_in_stock
Length (excluding null values)77             77             77
Max            77             263.5          125
Min            1              2.5            0
Mean           39             28.866365      40.506493

Note that all the numbers were inferred as Single (float), even the ids and the stock counts.


The DataFrame.LoadCsv method infers the column types automatically, but the guess is not always what we need. We can specify the column data types with the dataTypes option, and the column names with columnNames.

Program.cs
using Microsoft.Data.Analysis;

var df = DataFrame.LoadCsv("products.csv",
    dataTypes: [typeof(int), typeof(string), typeof(string), typeof(decimal), typeof(int)]);

Console.WriteLine(df.Info());

// the same with explicit column names
var df2 = DataFrame.LoadCsv("products.csv", header: true, columnNames: ["Id", "Name", "Category", "Price", "Stock"],
    dataTypes: [typeof(int), typeof(string), typeof(string), typeof(float), typeof(int)]);

Console.WriteLine(string.Join(", ", df2.Columns.Select(c => $"{c.Name}:{c.DataType.Name}")));

In the example, we specify the data types for the five columns. The unit_price becomes decimal, which is the better type for money, and the ids and counts become integers. The second call also renames the columns.

$ dotnet run
Info           id             product_name   category       unit_price     units_in_stock
DataType       System.Int32   System.String  System.String  System.Decimal System.Int32
Length (excluding null values)77             77             77             77             77

Id:Int32, Name:String, Category:String, Price:Single, Stock:Int32

C# DataFrame Head/Tail

The Head method returns the first n rows, while the Tail method returns the last n rows.

Program.cs
using Microsoft.Data.Analysis;

var df = DataFrame.LoadCsv("products.csv");

Console.WriteLine(df.Head(4));
Console.WriteLine(df.Tail(4));

The example shows the first four and the last four rows of the dataframe.

$ dotnet run
id             product_name   category       unit_price     units_in_stock
1              Chai           Beverages      18             39
2              Chang          Beverages      19             17
3              Aniseed Syrup  Condiments     10             13
4              Chef Anton's Cajun SeasoningCondiments     22             53

id             product_name   category       unit_price     units_in_stock
74             Longlife Tofu  Produce        10             4
75             Rhönbräu KlosterbierBeverages      7.75           125
76             Lakkalikööri   Beverages      18             57
77             Original Frankfurter grüne SoßeCondiments     13             32

The printed table uses fixed-width columns, so long values run into the next column. For nicer output we format the rows ourselves, as shown in the next sections, or use a library such as Spectre.Console (see the Spectre.Console tutorial).

C# DataFrame access to data

The cells are available through indexers: df[row, column], or a column by name or position followed by the row. A row is a DataFrameRow, which we can enumerate; the values are object instances, so they must be cast or formatted.

Program.cs
using Microsoft.Data.Analysis;

var df = DataFrame.LoadCsv("products.csv");

// access to the cells
Console.WriteLine(df[0, 1]);                       // row 0, column 1
Console.WriteLine(df["product_name"][2]);          // column by name, row 2

DataFrameRow row = df.Rows[8];
Console.WriteLine(string.Join(" | ", row));

// a typed column gives typed access
var prices = (SingleDataFrameColumn)df["unit_price"];
Console.WriteLine($"{prices.Length} prices, first: {prices[0]}");

foreach (var r in df.Rows.Take(3))
{
    Console.WriteLine($"{r[0],3} {r[1],-15} {r[3]:0.00}");
}

The program reads individual cells, a row, and a typed column.

var prices = (SingleDataFrameColumn)df["unit_price"];

Columns are objects of the typed classes, such as Int32DataFrameColumn, SingleDataFrameColumn, DecimalDataFrameColumn, and StringDataFrameColumn. After a cast, we get typed values and the operations of that type. A cell can be null, so the values are nullable.

$ dotnet run
Chai
Aniseed Syrup
9 | Mishi Kobe Niku | Meat/Poultry | 97 | 29
77 prices, first: 18
  1 Chai            18.00
  2 Chang           19.00
  3 Aniseed Syrup   10.00

C# DataFrame Filter

We use the Filter method to filter data. It takes a boolean column, which we create by comparing a column with a value using the Elementwise... methods.

Program.cs
using Microsoft.Data.Analysis;

var df = DataFrame.LoadCsv("products.csv");

PrimitiveDataFrameColumn<bool> expensive = df["unit_price"].ElementwiseGreaterThan(100);
Console.WriteLine(df.Filter(expensive));

Console.WriteLine(df.Filter((PrimitiveDataFrameColumn<bool>)df["units_in_stock"].ElementwiseLessThan(5)).Rows.Count);

// several conditions are combined with And / Or
PrimitiveDataFrameColumn<bool> isBeverage = df["category"].ElementwiseEquals("Beverages");
PrimitiveDataFrameColumn<bool> isCheap = df["unit_price"].ElementwiseLessThan(15);

Console.WriteLine(df.Filter((PrimitiveDataFrameColumn<bool>)isBeverage.And(isCheap)));

In the program, we apply three filters.

PrimitiveDataFrameColumn<bool> expensive = df["unit_price"].ElementwiseGreaterThan(100);
Console.WriteLine(df.Filter(expensive));

In the first filter, we show all products whose price is greater than 100. The comparison returns a column of booleans, one per row, and Filter keeps the rows where the value is true.

Console.WriteLine(df.Filter((PrimitiveDataFrameColumn<bool>)df["units_in_stock"].ElementwiseLessThan(5)).Rows.Count);

The second filter counts the products with a stock of less than five items. The result of the generic ElementwiseLessThan must be cast to the boolean column type.

Console.WriteLine(df.Filter((PrimitiveDataFrameColumn<bool>)isBeverage.And(isCheap)));

Boolean columns are combined with And, Or, and Xor. Here we select the cheap beverages. The C# operators && and || do not work on columns.

$ dotnet run
id             product_name   category       unit_price     units_in_stock
29             Thüringer RostbratwurstMeat/Poultry   123.79         0
38             Côte de Blaye  Beverages      263.5          17

8
id             product_name   category       unit_price     units_in_stock
24             Guaraná FantásticaBeverages      4.5            20
34             Sasquatch Ale  Beverages      14             111
67             Laughing Lumberjack LagerBeverages      14             52
75             Rhönbräu KlosterbierBeverages      7.75           125

C# DataFrame sort

We sort data with OrderBy and OrderByDescending. They return a new dataframe and leave the original unchanged.

Program.cs
using Microsoft.Data.Analysis;

var df = DataFrame.LoadCsv("products.csv");

Console.WriteLine(df.OrderBy("unit_price").Head(5));
Console.WriteLine(df.OrderByDescending("unit_price").Head(3));

// sort by several columns by sorting twice, starting with the less important column
var sorted = df.OrderBy("unit_price").OrderBy("category");
Console.WriteLine(sorted.Head(5));

In the program, we sort the data by unit price, from the cheapest and from the most expensive, and then by category and price.

var sorted = df.OrderBy("unit_price").OrderBy("category");

There is no overload that takes several keys. The idea is to sort first by the less important key and then by the more important one, which relies on the second sort keeping the order of equal rows.

$ dotnet run
id             product_name   category       unit_price     units_in_stock
33             Geitost        Dairy Products 2.5            112
24             Guaraná FantásticaBeverages      4.5            20
13             Konbu          Seafood        6              24
52             Filo Mix       Grains/Cereals 7              38
54             Tourtière      Meat/Poultry   7.45           21

id             product_name   category       unit_price     units_in_stock
38             Côte de Blaye  Beverages      263.5          17
29             Thüringer RostbratwurstMeat/Poultry   123.79         0
9              Mishi Kobe NikuMeat/Poultry   97             29

id             product_name   category       unit_price     units_in_stock
38             Côte de Blaye  Beverages      263.5          17
34             Sasquatch Ale  Beverages      14             111
70             Outback Lager  Beverages      15             15
76             Lakkalikööri   Beverages      18             57
35             Steeleye Stout Beverages      18             20

The last block shows that this does not work here. Within the beverages, the Côte de Blaye at 263.5 comes before the Sasquatch Ale at 14, so the earlier order by price was not preserved: the sort does not guarantee to keep the order of equal rows. When the combined ordering matters, sort the rows with LINQ (OrderBy(...).ThenBy(...)) over a list of objects, or sort by a computed single key column.

C# DataFrame GroupBy

The GroupBy method groups the rows of the DataFrame by the unique values in a column. It returns a GroupBy object, on which we call the aggregate operations Count, Max, Min, Mean, Sum, and Product.

Program.cs
using Microsoft.Data.Analysis;

var df = DataFrame.LoadCsv("products.csv");

GroupBy g = df.GroupBy("category");

Console.WriteLine(g.Count());
Console.WriteLine(g.Max("unit_price"));
Console.WriteLine(g.Mean("unit_price"));
Console.WriteLine(g.Sum("units_in_stock"));

In the example, we group the products by category and compute the number of products, the maximum and the average price, and the total stock for each category.

GroupBy g = df.GroupBy("category");

We group the data by category. The grouping column becomes the first column of every result.

Console.WriteLine(g.Mean("unit_price"));

The aggregate methods take the name of the column to compute; Count counts the rows of each group in all columns.

$ dotnet run
category       id             product_name   unit_price     units_in_stock
Beverages      12             12             12             12
Condiments     12             12             12             12
Produce        5              5              5              5
Meat/Poultry   6              6              6              6
Seafood        12             12             12             12
Dairy Products 10             10             10             10
Confections    13             13             13             13
Grains/Cereals 7              7              7              7

category   unit_price
Beverages  263.5
Condiments 43.9
Produce    53
Meat/Poultry123.79
Seafood    62.5
Dairy Products55
Confections81
Grains/Cereals38

category   unit_price
Beverages  37.979166666666664
Condiments 23.0625
Produce    32.370001220703124
Meat/Poultry54.00666809082031
Seafood    20.682498931884766
Dairy Products28.729998779296874
Confections25.159998967097355
Grains/Cereals20.25

category       units_in_stock
Beverages      559
Condiments     507
Produce        100
Meat/Poultry   165
Seafood        701
Dairy Products 393
Confections    386
Grains/Cereals 308

The counts show that the largest category is Confections with 13 products. The means of the float prices show the floating-point noise (32.370001220703124); format them when presenting, or use the decimal type for the column.

C# DataFrame computed columns

Columns support arithmetic with other columns and with values. The result is a new column, which we can add to the dataframe. We can also compute values in a loop and fill a new column, rename and remove columns, and use the statistics methods.

Program.cs
using Microsoft.Data.Analysis;

var df = DataFrame.LoadCsv("products.csv");

// a computed column: the value of the stock of each product
DataFrameColumn stockValue = df["unit_price"] * df["units_in_stock"];
stockValue.SetName("stock_value");
df.Columns.Add(stockValue);

foreach (var r in df.Rows.Take(3))
{
    Console.WriteLine($"{r["product_name"],-15} {r["stock_value"]}");
}

// column statistics
var price = df["unit_price"];
Console.WriteLine($"max {price.Max()}, min {price.Min()}, sum {price.Sum():F2}");

// a string column from a computation
var label = new StringDataFrameColumn("label", df.Rows.Count);
for (long i = 0; i < df.Rows.Count; i++)
{
    label[i] = (float)df["unit_price"][i] > 50 ? "premium" : "regular";
}
df.Columns.Add(label);

PrimitiveDataFrameColumn<bool> premium = df["label"].ElementwiseEquals("premium");
Console.WriteLine(df.Filter(premium).Rows.Count);

// removing and renaming
df.Columns.Remove("label");
df.Columns["unit_price"].SetName("price");
Console.WriteLine(string.Join(", ", df.Columns.Select(c => c.Name)));

The program computes the value of the stock of each product, labels expensive products, and renames columns.

DataFrameColumn stockValue = df["unit_price"] * df["units_in_stock"];
stockValue.SetName("stock_value");
df.Columns.Add(stockValue);

The multiplication works elementwise on two columns of the same length. The new column has an automatic name, so we set it before adding it.

var price = df["unit_price"];
Console.WriteLine($"max {price.Max()}, min {price.Min()}, sum {price.Sum():F2}");

Numeric columns have the Max, Min, and Sum methods, among others; the results are object values.

var label = new StringDataFrameColumn("label", df.Rows.Count);

We create a string column of the right length and fill it row by row. The 7 products with a price above 50 are the premium ones.

$ dotnet run
Chai            702
Chang           323
Aniseed Syrup   130
max 263.5, min 2.5, sum 2222.71
7
id, product_name, category, price, units_in_stock, stock_value

C# DataFrame create and save

A dataframe can be built in code from typed columns, extended with rows, and saved to a CSV file.

Program.cs
using Microsoft.Data.Analysis;

// a dataframe built in code
var ids = new Int32DataFrameColumn("Id", [1, 2, 3, 4]);
var names = new StringDataFrameColumn("Name", ["Audi", "Skoda", "Volvo", "Bentley"]);
var prices = new DoubleDataFrameColumn("Price", [52642, 9000, 29000, 350000]);

var df = new DataFrame(ids, names, prices);
Console.WriteLine(df);

// append rows
df.Append(new List<KeyValuePair<string, object>>
{
    new("Id", 5), new("Name", "Citroen"), new("Price", 21000.0)
}, inPlace: true);

Console.WriteLine(df.Rows.Count);

// save to CSV
DataFrame.SaveCsv(df, "cars.csv");
Console.WriteLine(File.ReadAllText("cars.csv"));

The program creates a dataframe of cars, appends a row, and writes it to cars.csv.

var ids = new Int32DataFrameColumn("Id", [1, 2, 3, 4]);
var names = new StringDataFrameColumn("Name", ["Audi", "Skoda", "Volvo", "Bentley"]);

Each column class takes the name and the values. All columns of a dataframe must have the same length.

df.Append(new List<KeyValuePair<string, object>> { ... }, inPlace: true);

The Append method adds a row, given as a list of column name and value pairs. With inPlace: true the dataframe itself is changed; otherwise a new one is returned. Appending rows one at a time is slow for a big dataframe.

$ dotnet run
Id        Name      Price
1         Audi      52642
2         Skoda     9000
3         Volvo     29000
4         Bentley   350000

5
Id,Name,Price
1,Audi,52642
2,Skoda,9000
3,Volvo,29000
4,Bentley,350000
5,Citroen,21000

C# DataFrame missing values

Real data has gaps. Every column can hold null, and the library has methods to count, drop, and fill them.

Program.cs
using Microsoft.Data.Analysis;

var id = new Int32DataFrameColumn("Id", [1, 2, 3]);
var name = new StringDataFrameColumn("Name", ["Audi", null, "Volvo"]);
var price = new DoubleDataFrameColumn("Price", [52642, 9000, 29000]);
price[1] = null;

var df = new DataFrame(id, name, price);
Console.WriteLine(df);

Console.WriteLine(df["Price"].NullCount);
Console.WriteLine(df.DropNulls().Rows.Count);

df["Price"].FillNulls(0.0, inPlace: true);
df["Name"].FillNulls("unknown", inPlace: true);
Console.WriteLine(df);

The program creates a dataframe with two missing values and handles them.

Console.WriteLine(df["Price"].NullCount);
Console.WriteLine(df.DropNulls().Rows.Count);

NullCount tells how many values are missing in a column; the price has one. DropNulls returns a dataframe without the rows that contain any null. The second row lacks both the name and the price, so it is removed and two rows remain.

df["Price"].FillNulls(0.0, inPlace: true);

FillNulls replaces the missing values with a given value, here in place.

$ dotnet run
Id        Name      Price
1         Audi      52642
2         null      null
3         Volvo     29000

1
2
Id        Name      Price
1         Audi      52642
2         unknown   0
3         Volvo     29000

C# DataFrame merge

Two dataframes can be combined on key columns, like a SQL join. The Merge method supports the inner, left, right and full outer joins.

Program.cs
using Microsoft.Data.Analysis;

var cars = new DataFrame(
    new Int32DataFrameColumn("Id", [1, 2, 3]),
    new StringDataFrameColumn("Name", ["Audi", "Skoda", "Volvo"]));

var orders = new DataFrame(
    new Int32DataFrameColumn("CarId", [1, 1, 3, 4]),
    new StringDataFrameColumn("Customer", ["John", "Jane", "Roger", "Lucia"]));

// inner join on the key columns
var joined = cars.Merge<int>(orders, "Id", "CarId", joinAlgorithm: JoinAlgorithm.Inner);
Console.WriteLine(joined);

var left = cars.Merge<int>(orders, "Id", "CarId", joinAlgorithm: JoinAlgorithm.Left);
Console.WriteLine(left.Rows.Count);

The program joins a table of cars with a table of orders.

var joined = cars.Merge<int>(orders, "Id", "CarId", joinAlgorithm: JoinAlgorithm.Inner);

The type argument is the type of the key columns; the next two arguments are the key column names in the left and in the right dataframe. The inner join keeps only the cars that have orders, and the Audi appears twice because it has two orders. The Skoda has no orders and the order of Lucia has no car, so both are missing.

$ dotnet run
Id        Name      CarId     Customer
1         Audi      1         John
1         Audi      1         Jane
3         Volvo     3         Roger

4

The left join keeps all four rows: the three matches and the Skoda without an order, with null values for the order columns.

Source

Getting started with DataFrames

In this article we have worked with the Microsoft.Data.Analysis DataFrame in C#.

Author

My name is Jan Bodnar, and I am a passionate programmer with extensive programming experience. I have been writing programming articles since 2007. To date, I have authored over 1,400 articles and 8 e-books. I possess more than ten years of experience in teaching programming.

List all C# tutorials.