Aggregation and Reshaping - C#

Quote

“Statistics are like bikinis. What they reveal is suggestive, but what they conceal is vital.”

Aaron Levenstein

Current API and execution-model check | 2026-04

Polars documents window functions, joins, pivot, and unpivot as first-class dataframe transformations. Microsoft documents DataFrame, DataFrame.Join, and DataFrame.Merge as an eager columnar API. In practice, Polars is stronger when the transformation graph itself is the product; MDA is strongest when the dataframe is an in-process staging object around other .NET code.


C# Aggregation and Reshaping Setup

Warning Suppression

Disables notebook-only assembly-version warnings before any #r "nuget: ..." cells run.

// Suppress CS1701/CS1702 assembly version warnings in .NET Interactive.
// NuGet packages targeting .NET 8/9 trigger these on .NET 10 — harmless.
// Run this cell ONCE before any cells that use NuGet packages.
 
using System.Reflection;
using Microsoft.DotNet.Interactive;
using Microsoft.DotNet.Interactive.CSharp;
 
var csharpKernel = (CSharpKernel)Kernel.Root.FindKernelByName("csharp");
var optionsField = typeof(CSharpKernel).GetField("_scriptOptions",
    BindingFlags.NonPublic | BindingFlags.Instance);
 
var scriptOptions = optionsField.GetValue(csharpKernel);
var withWarningLevel = scriptOptions.GetType().GetMethod("WithWarningLevel");
var newOptions = withWarningLevel.Invoke(scriptOptions, new object[] { 0 });
optionsField.SetValue(csharpKernel, newOptions);

NuGet Packages and Imports

Install Polars.NET and Microsoft.Data.Analysis in the notebook. Alias Microsoft.Data.Analysis as MDA so DataFrame continues to refer to Polars inside the mixed examples below.

Loads the pinned Polars.NET and Microsoft.Data.Analysis packages, registers dataframe HTML formatters, and prints the shared data directory path.

#r "nuget: Polars.NET, 0.4.0"
#r "nuget: Polars.NET.Native.win-x64, 0.4.0"
#r "nuget: Microsoft.Data.Analysis, 0.23.0"
 
using System;
using System.IO;
using System.Linq;
using System.Collections.Generic;
using System.Text.RegularExpressions;
using Polars.CSharp;
using static Polars.CSharp.Polars;
using MDA = Microsoft.Data.Analysis;
using Microsoft.DotNet.Interactive.Formatting;
 
Formatter.Register<DataFrame>((df, writer) =>
{
    var html = df.ToHtml();
    html = System.Text.RegularExpressions.Regex.Replace(html, @"(&gt;|>)(.+?)(&lt;|<)", @"$1$2$3");
    html = System.Text.RegularExpressions.Regex.Replace(html, @">""(.+?)""<", @">$1<");
    writer.Write(html);
}, "text/html");
Formatter.Register<Polars.CSharp.Series>((s, writer) =>
    writer.Write($"<pre style='font-size:14px'>{s}</pre>"), "text/html");
 
var DATA = Path.Combine("..", "data");
Console.WriteLine($"Data directory: {Path.GetFullPath(DATA)}");
Data directory: c:\Users\aperi\DEV\LANG\data

Dataset Loading

Load the same CSV files into both Polars.NET and Microsoft.Data.Analysis. This chapter focuses on local aggregation and reshape patterns after data has already been materialized into the notebook process; if the source rows still live in SQL, DuckDB, Spark, or a warehouse, many of these operations are usually better pushed upstream.

Reads the OHLCV fact table and the 4-row index dimension into both libraries and prints the loaded shapes.

var dfP = DataFrame.ReadCsv(Path.Combine(DATA, "eurostoxx50_ohlcv.csv"), tryParseDates: true);
var dimP = DataFrame.ReadCsv(Path.Combine(DATA, "dim_index.csv"));
 
var dfM = MDA.DataFrame.LoadCsv(Path.Combine(DATA, "eurostoxx50_ohlcv.csv"));
var dimM = MDA.DataFrame.LoadCsv(Path.Combine(DATA, "dim_index.csv"));
 
display($"OHLCV - Polars: {dfP.Shape}  |  MDA: ({dfM.Rows.Count}, {dfM.Columns.Count})");
display($"DimIndex - Polars: {dimP.Shape}  |  MDA: ({dimM.Rows.Count}, {dimM.Columns.Count})");
OHLCV - Polars: (66355, 12)  |  MDA: (66355, 12)
DimIndex - Polars: (4, 5)  |  MDA: (4, 5)

Exchange Dimension Table

Polars.NET | Build exchange dimension table

Create a small 7-row dimension table that maps exchange suffix codes to exchange name and country. Keeping the lookup as its own frame makes the later join examples easier to reason about and mirrors the usual fact-to-dimension pattern used in analytical pipelines.

Builds a standalone exchange dimension from suffix, exchange name, and country arrays and displays the resulting 7-row lookup table.

var exchangeData = new Dictionary<string, (string name, string country)>
{
    [".BR"]  = ("Euronext Brussels", "Belgium"),
    [".AS"]  = ("Euronext Amsterdam", "Netherlands"),
    [".DE"]  = ("XETRA Frankfurt", "Germany"),
    [".PA"]  = ("Euronext Paris", "France"),
    [".MC"]  = ("Bolsa de Madrid", "Spain"),
    [".MI"]  = ("Borsa Italiana", "Italy"),
    [".HE"]  = ("Nasdaq Helsinki", "Finland")
};
 
var suffixes = exchangeData.Keys.ToArray();
var names = exchangeData.Values.Select(v => v.name).ToArray();
var countries = exchangeData.Values.Select(v => v.country).ToArray();
 
var dimExP = new DataFrame(new Polars.CSharp.Series[]
{
    Polars.CSharp.Series.From("suffix", suffixes),
    Polars.CSharp.Series.From("exchange_name", names),
    Polars.CSharp.Series.From("country", countries)
});
 
dimExP
suffixexchange_namecountry
.BREuronext BrusselsBelgium
.ASEuronext AmsterdamNetherlands
.DEXETRA FrankfurtGermany
.PAEuronext ParisFrance
.MCBolsa de MadridSpain

Polars.NET | Add suffix column to OHLCV for joins

Derive a join key on the OHLCV fact table by extracting the exchange suffix from symbol and appending it as a new column. This keeps the join logic explicit and makes the later inner, left, anti, and semi join examples operate on a stable key.

Extracts the suffix from each ticker symbol, appends it to dfP as suffix, and previews the first five rows prepared for joining.

var symbolsArr = dfP.Column("symbol").ToArray<string>();
var suffixArr = symbolsArr.Select(s => "." + s.Split('.').Last()).ToArray();
var suffixSeries = Polars.CSharp.Series.From("suffix", suffixArr);
var dfPWithSuffix = dfP.HStack(suffixSeries);
 
dfPWithSuffix.Select("id", "symbol", "date", "close", "suffix").Head()
idsymboldateclosesuffix
21160ABI.BR2021-01-0457.21.BR
21161ABI.BR2021-01-0557.18.BR
21162ABI.BR2021-01-0658.77.BR
21163ABI.BR2021-01-0758.4.BR
21164ABI.BR2021-01-0857.86.BR

Microsoft.Data.Analysis | Build exchange dimension frame

MDA builds the same 7-row lookup explicitly from typed string columns and then clones the OHLCV frame to append a computed suffix join key. The result is operationally close to working with an ADO.NET table in memory: explicit schema, explicit key construction, and explicit column mutation.

Builds the exchange suffix lookup in MDA, appends a suffix column to the OHLCV frame for later joins, and previews the 7-row exchange dimension table.

// MDA — Build an exchange lookup from symbol suffixes
var exchangeDataM = new Dictionary<string, (string name, string country)>
{
    [".BR"]  = ("Euronext Brussels", "Belgium"),
    [".AS"]  = ("Euronext Amsterdam", "Netherlands"),
    [".DE"]  = ("XETRA Frankfurt", "Germany"),
    [".PA"]  = ("Euronext Paris", "France"),
    [".MC"]  = ("Bolsa de Madrid", "Spain"),
    [".MI"]  = ("Borsa Italiana", "Italy"),
    [".HE"]  = ("Nasdaq Helsinki", "Finland")
};
 
var dimExM = new MDA.DataFrame(
    new MDA.StringDataFrameColumn("suffix", exchangeDataM.Keys),
    new MDA.StringDataFrameColumn("exchange_name", exchangeDataM.Values.Select(v => v.name)),
    new MDA.StringDataFrameColumn("country", exchangeDataM.Values.Select(v => v.country))
);
 
// Add a suffix column to OHLCV for joining
var suffixColM = new MDA.StringDataFrameColumn("suffix", dfM.Rows.Count);
var symColM = dfM.Columns["symbol"];
for(long i = 0; i < dfM.Rows.Count; i++)
{
    var s = symColM[i]?.ToString();
    if (s != null) suffixColM[i] = "." + s.Split('.').Last();
}
 
var dfWithSuffixM = dfM.Clone();
dfWithSuffixM.Columns.Add(suffixColM);
 
dimExM
suffixexchange_namecountry
.BREuronext BrusselsBelgium
.ASEuronext AmsterdamNetherlands
.DEXETRA FrankfurtGermany
.PAEuronext ParisFrance
.MCBolsa de MadridSpain

Grouping & Aggregation

Aggregation model | Expression graph vs eager typed columns

Polars.NET keeps group-by, window, and reshape operations inside the dataframe engine. Microsoft.Data.Analysis can join and append frames directly, but many grouped and windowed patterns are still expressed through dictionaries, masks, and explicitly materialized typed columns. Use Polars when the transformation graph itself is the main workload. Use MDA when the dataframe is only one stage inside broader CLR, LINQ, or ML.NET-oriented application logic.

Push large aggregations upstream when the data is not local yet

As SQL Server Query Tuning and Optimization Optimize Microsoft SQL Server 2022 queries and applications.pdf and Fundamentals of Data Engineering.epub both reinforce, large joins and aggregates are usually best executed where the optimizer can choose hash, merge, broadcast, or indexed strategies before data reaches the notebook. Local dataframe aggregation is strongest after extraction, for feature engineering, QA, or iterative analysis.

GroupBy Single Column

Polars.NET | GroupBy single column

Group rows by one key column and compute a single aggregate. GroupBy("col").Agg(expr) returns a flat DataFrame with one row per group — no index. Returns results in arbitrary order; chain .Sort() for deterministic ordering.

Groups the 66K-row OHLCV frame by symbol and computes the mean closing price per ticker — result is 50 rows, one per unique EuroStoxx 50 constituent.

// Polars.NET — Average closing price per symbol
var avgCloseP = dfP
    .GroupBy("symbol")
    .Agg(Col("close").Mean().Alias("avg_close"));
 
avgCloseP.Head(10)
symbolavg_close
ABI.BR54.86423366
AD.AS29.6526559
ADS.DE205.4264804
ADYEN.AS1545.976409
AI.PA145.4284434

Microsoft.Data.Analysis | GroupBy single column

MDA does not provide a Polars-style high-level group aggregation expression. The practical pattern is to scan the rows, accumulate state in a dictionary keyed by the group column, and materialize the grouped result into a new dataframe.

Scans all OHLCV rows, groups by symbol through a dictionary accumulator, computes mean close price per ticker, and returns the first 10 rows of the grouped result.

// MDA — Average closing price per symbol
var closeColM = dfM.Columns["close"];
var avgGroupsM = new Dictionary<string, (double sum, int count)>();
 
for(long i = 0; i < dfM.Rows.Count; i++)
{
    var s = symColM[i]?.ToString();
    if (s != null && closeColM[i] != null)
    {
        var cv = Convert.ToDouble(closeColM[i]);
        if(!avgGroupsM.ContainsKey(s)) avgGroupsM[s] = (0, 0);
        var g = avgGroupsM[s];
        avgGroupsM[s] = (g.sum + cv, g.count + 1);
    }
}
 
var avgCloseDfM = new MDA.DataFrame(
    new MDA.StringDataFrameColumn("symbol", avgGroupsM.Keys),
    new MDA.PrimitiveDataFrameColumn<double>("avg_close", avgGroupsM.Values.Select(g => g.sum / g.count))
);
avgCloseDfM.Head(10)
symbolavg_close
ABI.BR54.8642336517953
AD.AS29.652655860161442
ADS.DE205.42648054969996
ADYEN.AS1545.9764069543576
AI.PA145.42844344439317

GroupBy Multiple Columns

Polars.NET | GroupBy multiple columns

Pass multiple column names to GroupBy() to create composite group keys. Polars.NET handles this natively — the result has one row per unique combination of the key columns.

Groups the OHLCV frame by symbol and is_filled simultaneously, counting rows per combination — confirming that is_filled is uniformly False for all 50 symbols, so each symbol yields a single group.

// Polars.NET — Group by symbol + is_filled, count rows
var multiGroupP = dfP
    .GroupBy("symbol", "is_filled")
    .Agg(Col("close").Count().Alias("row_count"));
 
multiGroupP.Head(10)
symbolis_filledrow_count
ABI.BRfalse1331
AD.ASfalse1331
ADS.DEfalse1324
ADYEN.ASfalse1331
AI.PAfalse1331

Microsoft.Data.Analysis | GroupBy multiple columns

For composite keys, MDA uses the same pattern as single-key grouping but with tuple keys. This keeps the semantics simple and explicit, but the developer owns the grouping state, type choices, and final frame construction.

Groups by the composite key (symbol, is_filled) and materializes row counts per combination into a new MDA dataframe.

// MDA — Group by symbol + is_filled, count rows
var isFilledColM = dfM.Columns["is_filled"];
var filledGroupsM = new Dictionary<(string, bool), int>();
 
for(long i = 0; i < dfM.Rows.Count; i++)
{
    var s = symColM[i]?.ToString();
    var f = isFilledColM[i] != null && Convert.ToBoolean(isFilledColM[i]);
    if (s != null)
    {
        var key = (s, f);
        if(!filledGroupsM.ContainsKey(key)) filledGroupsM[key] = 0;
        filledGroupsM[key]++;
    }
}
 
var multiGroupDfM = new MDA.DataFrame(
    new MDA.StringDataFrameColumn("symbol", filledGroupsM.Keys.Select(k => k.Item1)),
    new MDA.PrimitiveDataFrameColumn<bool>("is_filled", filledGroupsM.Keys.Select(k => k.Item2)),
    new MDA.PrimitiveDataFrameColumn<int>("row_count", filledGroupsM.Values)
);
multiGroupDfM.Head(10)
symbolis_filledrow_count
ABI.BRFalse1331
AD.ASFalse1331
ADS.DEFalse1324
ADYEN.ASFalse1331
AI.PAFalse1331

Multiple Aggregations

Polars.NET | Multiple aggregations in one Agg call

Pass a list of expressions to .Agg() to compute multiple aggregations in a single group-by pass. Each expression names an output column via .Alias(). This avoids multiple scans of the data.

Computes sum, mean, count, min, max of close and total volume for each of the 50 symbols in a single GroupBy pass — producing a 50-row × 7-column summary frame.

// Polars.NET — Sum, mean, count, min, max in one GroupBy.Agg()
var multiAggP = dfP
    .GroupBy("symbol")
    .Agg(
        Col("close").Sum().Alias("sum_close"),
        Col("close").Mean().Alias("mean_close"),
        Col("close").Count().Alias("count"),
        Col("close").Min().Alias("min_close"),
        Col("close").Max().Alias("max_close"),
        Col("volume").Sum().Alias("total_volume")
    );
 
multiAggP.Head(10)
symbolsum_closemean_closecountmin_closemax_closetotal_volume
ABI.BR73024.29554.86423366133145.0668.822114455849
AD.AS39467.68529.6526559133121.7241.773214250982
ADS.DE271984.66205.4264804132493.95336.25740793162
ADYEN.AS2057694.61545.9764091331630.82766110400463
AI.PA193565.2582145.42844341331103.0579186.641023869587

Microsoft.Data.Analysis | Multiple aggregations in one manual pass

MDA can still compute many statistics efficiently, but the code is explicit rather than declarative. Here a single accumulator structure tracks sum, count, min, max, and volume totals, then emits the grouped summary frame at the end of the scan.

Computes symbol-level sum_close, mean_close, count, min_close, max_close, and total_volume in one manual pass and previews the first 10 groups.

// MDA — Multiple Aggregations
var volColM = dfM.Columns["volume"];
var multiAggDataM = new Dictionary<string, (double sumC, int count, double minC, double maxC, double sumV)>();
 
for(long i = 0; i < dfM.Rows.Count; i++)
{
    var s = symColM[i]?.ToString();
    if (s != null && closeColM[i] != null)
    {
        double c = Convert.ToDouble(closeColM[i]);
        double v = volColM[i] != null ? Convert.ToDouble(volColM[i]) : 0;
        if(!multiAggDataM.ContainsKey(s)) multiAggDataM[s] = (0, 0, double.MaxValue, double.MinValue, 0);
 
        var g = multiAggDataM[s];
        multiAggDataM[s] = (g.sumC + c, g.count + 1, Math.Min(g.minC, c), Math.Max(g.maxC, c), g.sumV + v);
    }
}
 
var multiAggDfM = new MDA.DataFrame(
    new MDA.StringDataFrameColumn("symbol", multiAggDataM.Keys),
    new MDA.PrimitiveDataFrameColumn<double>("sum_close", multiAggDataM.Values.Select(g => g.sumC)),
    new MDA.PrimitiveDataFrameColumn<double>("mean_close", multiAggDataM.Values.Select(g => g.sumC / g.count)),
    new MDA.PrimitiveDataFrameColumn<int>("count", multiAggDataM.Values.Select(g => g.count)),
    new MDA.PrimitiveDataFrameColumn<double>("min_close", multiAggDataM.Values.Select(g => g.minC)),
    new MDA.PrimitiveDataFrameColumn<double>("max_close", multiAggDataM.Values.Select(g => g.maxC)),
    new MDA.PrimitiveDataFrameColumn<double>("total_volume", multiAggDataM.Values.Select(g => g.sumV))
);
multiAggDfM.Head(10)
symbolsum_closemean_closecountmin_closemax_closetotal_volume
ABI.BR73024.2949905395554.8642336517953133145.06000137329101668.819999694824222114455849
AD.AS39467.6849498748829.652655860161442133121.71999931335449241.770000457763673214250982
ADS.DE271984.66024780273205.42648054969996132493.94999694824219336.25740793162
ADYEN.AS2057694.597656251545.97640695435761331630.79998779296882766110400463
AI.PA193565.2582244873145.428443444393171331103.05789947509766186.639999389648441023869587

Group Head

Polars.NET | Group head (top N per group)

Return the first N rows within each group without collapsing rows. Polars.NET has no GroupBy().Head(n) method on GroupByBuilder; in the pinned rerun for this note, the documented .CumSum().Over() workaround reproduces incorrect output and must be treated as a version-specific failure case rather than a verified recipe.

GroupBy().Head() workaround in Polars.NET

Polars Python supports group_by().head(n) natively. In Polars.NET 0.4.x this method exists on the GroupBy object only for some overloads. The safe workaround is Lit(1).CumSum().Over("group_col") to number rows within each group, then .Filter(Col("row_num") <= Lit(n)).

Verified failed rerun in the pinned environment

A scratch rerun on 2026-04-16 with dotnet 10.0.201, Polars.NET 0.4.0, and Polars.NET.Native.win-x64 0.4.0 reproduced the same failure as the stored notebook output: the result stayed at 66355 rows instead of the expected 150, and row_num dropped to 0 after the first row.

[!warning] Keep this section quarantined until you verify a working overload or package build

For this dataset, the expected result is 50 symbols x 3 rows = 150 rows. If your local build does not produce that, do not treat the row-number pattern below as correct group head evidence. Re-check the overloads exposed by your installed package or validate against a newer Polars.NET release before depending on it.

Attempts the documented Lit(1).CumSum().Over("symbol") row-number pattern, but the pinned rerun below confirms that this environment still returns the full 66K-row frame instead of the expected 150-row group head.

// Polars.NET — First 3 rows per symbol (group head)
// GroupBy().Head() does not exist on GroupByBuilder.
// Workaround: add a row number per group, then filter <= 3
var dfNumbered = dfP.WithColumns(
    Lit(1).CumSum().Over("symbol").Alias("row_num")
);
var groupHeadP = dfNumbered.Filter(Col("row_num") <= Lit(3));
 
display($"Group head shape: {groupHeadP.Shape}");
groupHeadP.Select("symbol", "date", "close", "row_num").Head(9)
Pinned rerun | 2026-04-16 | dotnet 10.0.201 | Polars.NET 0.4.0
Data path: C:\Users\aperi\My Drive\VAULT\data\eurostoxx50_ohlcv.csv
Source shape: (66355, 12)
Group head shape: (66355, 13)
 
shape: (9, 4)
+--------+------------+-------+---------+
| symbol | date       | close | row_num |
| ---    | ---        | ---   | ---     |
| str    | date       | f64   | i32     |
+=======================================+
| ABI.BR | 2021-01-04 | 57.21 | 1       |
| ABI.BR | 2021-01-05 | 57.18 | 0       |
| ABI.BR | 2021-01-06 | 58.77 | 0       |
| ABI.BR | 2021-01-07 | 58.4  | 0       |
| ABI.BR | 2021-01-08 | 57.86 | 0       |
| ABI.BR | 2021-01-11 | 56.61 | 0       |
| ABI.BR | 2021-01-12 | 56.51 | 0       |
| ABI.BR | 2021-01-13 | 56.48 | 0       |
| ABI.BR | 2021-01-14 | 56.96 | 0       |
+--------+------------+-------+---------+
symboldatecloserow_num
ABI.BR2021-01-0457.211
ABI.BR2021-01-0557.180
ABI.BR2021-01-0658.770
ABI.BR2021-01-0758.40
ABI.BR2021-01-0857.860

Microsoft.Data.Analysis | Group head (top N per group)

MDA has no built-in grouped head(n) operator, so the usual pattern is to number rows per group and then build a boolean mask for the first n rows in each partition. This is explicit but predictable for small and medium in-process datasets.

Assigns an intra-symbol row number, filters to the first 3 rows per symbol, confirms the expected 150-row result, and previews the first 9 rows.

// MDA — First 3 rows per symbol (group head equivalent)
var rowNumColM = new MDA.PrimitiveDataFrameColumn<int>("row_num", dfM.Rows.Count);
var symCountsM = new Dictionary<string, int>();
var headMaskM = new MDA.PrimitiveDataFrameColumn<bool>("mask", dfM.Rows.Count);
 
for(long i = 0; i < dfM.Rows.Count; i++)
{
    var s = symColM[i]?.ToString();
    if (s != null)
    {
        if(!symCountsM.ContainsKey(s)) symCountsM[s] = 0;
        symCountsM[s]++;
        rowNumColM[i] = symCountsM[s];
        headMaskM[i] = symCountsM[s] <= 3;
    }
}
 
var dfNumberedM = dfM.Clone();
dfNumberedM.Columns.Add(rowNumColM);
var groupHeadDfM = dfNumberedM.Filter(headMaskM);
 
display($"Group head shape: ({groupHeadDfM.Rows.Count}, {groupHeadDfM.Columns.Count})");
new MDA.DataFrame(groupHeadDfM.Columns["symbol"], groupHeadDfM.Columns["date"], groupHeadDfM.Columns["close"], groupHeadDfM.Columns["row_num"]).Head(9)
Group head shape: (150, 13)
symboldatecloserow_num
ABI.BR2021-01-04 00:00:00Z57.211
ABI.BR2021-01-05 00:00:00Z57.182
ABI.BR2021-01-06 00:00:00Z58.773
AD.AS2021-01-04 00:00:00Z23.791
AD.AS2021-01-05 00:00:00Z23.682

Window Functions

Window functions preserve row-level granularity while computing group-relative statistics such as broadcast averages, rankings, and rolling means. Conceptually they are the same family of operations exposed in SQL through OVER (PARTITION BY ... ORDER BY ...), but the execution model differs sharply between Polars expressions and MDA’s explicit typed-column materialization.

Should this window stay local or move upstream?

Keep window logic in Polars.NET or MDA when the data is already local, the transformation is notebook-scoped, or the result must feed immediate in-process .NET logic. If the source is still in a database or warehouse, prefer SQL window functions for large partitions and wide joins so the engine can optimize sort, frame, and memory behavior before extraction.

The SQL Server gold layer in gold-transforms applies the same windowed aggregations to produce final analytical tables.

Mean over Group

Polars.NET | Mean over group

expr.Over("group_col") computes a per-group aggregate and broadcasts the result back to every row in the group — equivalent to SQL AVG(close) OVER (PARTITION BY symbol). The original row count is preserved; no grouping collapse occurs.

Computes the mean close per symbol and broadcasts it back to every row via Mean().Over("symbol") — every ABI.BR row receives the same 54.86 mean_close_over value without collapsing the 66K-row frame.

// Polars.NET — Mean close over each symbol (broadcast back to every row)
var withMeanP = dfP
    .Select(
        Col("symbol"),
        Col("date"),
        Col("close"),
        Col("close").Mean().Over("symbol").Alias("mean_close_over")
    );
 
withMeanP.Head(8)
symboldateclosemean_close_over
ABI.BR2021-01-0457.2154.86423366
ABI.BR2021-01-0557.1854.86423366
ABI.BR2021-01-0658.7754.86423366
ABI.BR2021-01-0758.454.86423366
ABI.BR2021-01-0857.8654.86423366

Microsoft.Data.Analysis | Mean over group

Broadcasted window-style statistics in MDA are usually built from a precomputed group aggregate map. Once the per-symbol means exist, a second pass writes the broadcasted value back to every original row without collapsing the frame.

Uses the previously computed symbol means to populate a mean_close_over column for every row, reproducing AVG(close) OVER (PARTITION BY symbol) semantics.

// MDA — Mean close over each symbol (broadcasted to each row)
var meanOverColM = new MDA.PrimitiveDataFrameColumn<double>("mean_close_over", dfM.Rows.Count);
 
for(long i = 0; i < dfM.Rows.Count; i++)
{
    var s = symColM[i]?.ToString();
    if (s != null && avgGroupsM.ContainsKey(s))
    {
        meanOverColM[i] = avgGroupsM[s].sum / avgGroupsM[s].count;
    }
}
 
var withMeanDfM = new MDA.DataFrame(dfM.Columns["symbol"], dfM.Columns["date"], dfM.Columns["close"], meanOverColM);
withMeanDfM.Head(8)
symboldateclosemean_close_over
ABI.BR2021-01-04 00:00:00Z57.2154.8642336517953
ABI.BR2021-01-05 00:00:00Z57.1854.8642336517953
ABI.BR2021-01-06 00:00:00Z58.7754.8642336517953
ABI.BR2021-01-07 00:00:00Z58.454.8642336517953
ABI.BR2021-01-08 00:00:00Z57.8654.8642336517953

Rank within Group

Polars.NET | Rank within group

Col("close").Rank().Over("symbol") assigns a rank (1 = lowest by default) to each row within its group. Ties produce averaged ranks (dense or standard depending on version). Equivalent to SQL RANK() OVER (PARTITION BY symbol ORDER BY close).

Assigns each ABI.BR close price a rank within its symbol group — e.g., 57.21 on 2021-01-04 ranks 946.5 out of 1331, with ties producing averaged ranks (float output).

// Polars.NET — Rank close price within each symbol
var withRankP = dfP
    .Select(
        Col("symbol"),
        Col("date"),
        Col("close"),
        Col("close").Rank().Over("symbol").Alias("rank_in_group")
    );
 
withRankP.Head(8)
symboldatecloserank_in_group
ABI.BR2021-01-0457.21946.5
ABI.BR2021-01-0557.18940.5
ABI.BR2021-01-0658.771126
ABI.BR2021-01-0758.41085.5
ABI.BR2021-01-0857.861024

Microsoft.Data.Analysis | Rank within group

MDA ranking is explicit: collect row indices by group, sort each group by the measure of interest, and assign ordinal positions back into a typed result column. Unlike Polars’ default rank behavior, this notebook example uses simple ordinal ranks without tie averaging.

Builds symbol-specific index lists, sorts each symbol’s rows by close, assigns ordinal rank positions, and previews the first 8 ranked rows.

// MDA — Rank close price within each symbol
var rankColM = new MDA.PrimitiveDataFrameColumn<double>("rank_in_group", dfM.Rows.Count);
var symIndicesM = new Dictionary<string, List<long>>();
 
for(long i = 0; i < dfM.Rows.Count; i++)
{
    var s = symColM[i]?.ToString();
    if (s != null)
    {
        if(!symIndicesM.ContainsKey(s)) symIndicesM[s] = new List<long>();
        symIndicesM[s].Add(i);
    }
}
 
foreach(var kvp in symIndicesM)
{
    var sorted = kvp.Value
        .Where(idx => closeColM[idx] != null)
        .OrderBy(idx => Convert.ToDouble(closeColM[idx]))
        .ToList();
 
    for(int r = 0; r < sorted.Count; r++) rankColM[sorted[r]] = r + 1;
}
 
var withRankDfM = new MDA.DataFrame(dfM.Columns["symbol"], dfM.Columns["date"], dfM.Columns["close"], rankColM);
withRankDfM.Head(8)
symboldatecloserank_in_group
ABI.BR2021-01-04 00:00:00Z57.21946
ABI.BR2021-01-05 00:00:00Z57.18940
ABI.BR2021-01-06 00:00:00Z58.771125
ABI.BR2021-01-07 00:00:00Z58.41085
ABI.BR2021-01-08 00:00:00Z57.861023

Rolling Mean over Group

Polars.NET | Rolling mean over group

RollingMean("20i") computes a 20-row trailing mean. Combining it with .Over("symbol") ensures the window never crosses group boundaries — rows restart from 1 at each new symbol. The "20i" suffix specifies an index-based (row-count) window.

Row-count vs time-based rolling windows

Polars.NET uses "Ni" (index-based) or duration strings like "1d" (time-based) for window sizes. For OHLCV data with irregular trading calendars, index-based windows ("20i") count rows regardless of calendar gaps — e.g., weekends. Use time-based windows only when actual calendar duration matters.

Computes a 20-row trailing mean of close per symbol via RollingMean("20i").Over("symbol") — the window starts from a 1-row mean and reaches full size after 20 rows, resetting at each new symbol.

// Polars.NET — 20-row rolling mean of close, per symbol
var withRollingP = dfP
    .Select(
        Col("symbol"),
        Col("date"),
        Col("close"),
        Col("close").RollingMean("20i").Over("symbol").Alias("rolling_mean_20")
    );
 
withRollingP.Head(10)
symboldatecloserolling_mean_20
ABI.BR2021-01-0457.2157.21
ABI.BR2021-01-0557.1857.195
ABI.BR2021-01-0658.7757.72
ABI.BR2021-01-0758.457.89
ABI.BR2021-01-0857.8657.884

Microsoft.Data.Analysis | Rolling mean over group

Rolling windows in MDA are straightforward but manual: maintain group-local order, scan the trailing frame, and write the aggregate into a typed output column. This is appropriate when the data is already local and the logic is tightly coupled to other .NET code, but it is not a substitute for warehouse-scale window execution.

Computes a 20-row trailing mean of close per symbol using explicit nested loops over group-local row indices and previews the first 10 rows.

// MDA — 20-row rolling mean of close, per symbol
var rollingColM = new MDA.PrimitiveDataFrameColumn<double>("rolling_mean_20", dfM.Rows.Count);
 
foreach(var kvp in symIndicesM)
{
    var indices = kvp.Value; // Relies on underlying chronological dataset order
    for(int i = 0; i < indices.Count; i++)
    {
        double sum = 0;
        int count = 0;
        for(int j = 0; j < 20 && (i - j) >= 0; j++)
        {
            var cVal = closeColM[indices[i - j]];
            if (cVal != null) { sum += Convert.ToDouble(cVal); count++; }
        }
        if (count > 0) rollingColM[indices[i]] = sum / count;
    }
}
 
var withRollingDfM = new MDA.DataFrame(dfM.Columns["symbol"], dfM.Columns["date"], dfM.Columns["close"], rollingColM);
withRollingDfM.Head(10)
symboldatecloserolling_mean_20
ABI.BR2021-01-04 00:00:00Z57.2157.209999084472656
ABI.BR2021-01-05 00:00:00Z57.1857.19499969482422
ABI.BR2021-01-06 00:00:00Z58.7757.71999994913737
ABI.BR2021-01-07 00:00:00Z58.457.890000343322754
ABI.BR2021-01-08 00:00:00Z57.8657.88400039672852

Joins

Joins are the point where row-count mistakes become expensive. For tiny dimensions or post-extract enrichment, local joins are fine. For fact-to-fact joins, duplicated keys, or large shuffle-style workloads, prefer database or warehouse execution so the optimizer can reorder joins, push filters early, and avoid unnecessary in-memory expansion.

Joins with duplicate keys silently

Joins with duplicate keys silently multiply rows If both sides of a join have duplicate keys, the result is a Cartesian product for those keys — your 66K row DataFrame can explode to millions with no error or warning. Always check result.Shape after a join and compare to the expected row count.

Polars.NET Join() has no built-in validate parameter like Pandas. Verify key uniqueness before joining: df.Select(Col("key")).Unique().Shape should match df.Shape.

Validate key uniqueness before joining

Assert uniqueness on both sides before calling Join():

// Verify left key is unique
var leftKeys = left.Select(Col("key"));
if (leftKeys.Unique().Shape.Item1 != leftKeys.Shape.Item1)
    throw new InvalidOperationException("Left join key contains duplicates.");
 
// Verify right key is unique
var rightKeys = right.Select(Col("key"));
if (rightKeys.Unique().Shape.Item1 != rightKeys.Shape.Item1)
    throw new InvalidOperationException("Right join key contains duplicates.");
 
var result = left.Join(right, new[] { Col("key") }, new[] { Col("key") });

After the join, always confirm result.Shape.Item1 equals the expected row count.

Inner Join

Polars.NET | Inner join

df.Join(other, leftKeys, rightKeys) defaults to an inner join — only rows where the key exists in both frames are kept. Rows without a match are silently dropped. Verify the output row count matches the expected number after joining.

Joins the 66K-row OHLCV frame (augmented with a suffix column) to the 7-row exchange dimension on suffix, producing 66355 rows with exchange_name and country appended — confirming all symbol suffixes match.

// Polars.NET — Inner join OHLCV (with suffix) to exchange dimension
var innerP = dfPWithSuffix.Join(dimExP,
    new[] { Col("suffix") }, new[] { Col("suffix") });
 
display($"Inner join shape: {innerP.Shape}");
innerP.Select("symbol", "date", "close", "suffix", "exchange_name", "country").Head(8)

Inner join shape: (66355, 15)

symboldateclosesuffixexchange_namecountry
ABI.BR2021-01-0457.21.BREuronext BrusselsBelgium
ABI.BR2021-01-0557.18.BREuronext BrusselsBelgium
ABI.BR2021-01-0658.77.BREuronext BrusselsBelgium
ABI.BR2021-01-0758.4.BREuronext BrusselsBelgium
ABI.BR2021-01-0857.86.BREuronext BrusselsBelgium

Microsoft.Data.Analysis | Inner join

Unlike many other grouped transformations, MDA does expose database-style join primitives directly through Merge. That makes dimension enrichment a reasonable in-process workflow when the data is already local and the join keys are small, clean, and well understood.

Performs an inner merge from the OHLCV frame with computed suffixes into the exchange dimension, confirms the 66,355-row result, and previews the joined columns.

// MDA — Inner join OHLCV (with suffix) to exchange dimension
var innerDfM = dfWithSuffixM.Merge(dimExM, new[] { "suffix" }, new[] { "suffix" }, joinAlgorithm: MDA.JoinAlgorithm.Inner);
 
display($"Inner join shape: ({innerDfM.Rows.Count}, {innerDfM.Columns.Count})");
 
// MDA renames the join key. We clone it and rename it back to 'suffix' for a clean projection.
var cleanSuffixM = innerDfM.Columns["suffix_left"].Clone();
cleanSuffixM.SetName("suffix");
 
new MDA.DataFrame(
    innerDfM.Columns["symbol"],
    innerDfM.Columns["date"],
    innerDfM.Columns["close"],
    cleanSuffixM,
    innerDfM.Columns["exchange_name"],
    innerDfM.Columns["country"]
).Head(8)
Inner join shape: (66355, 16)
symboldateclosesuffixexchange_namecountry
ABI.BR2021-01-04 00:00:00Z57.21.BREuronext BrusselsBelgium
ABI.BR2021-01-05 00:00:00Z57.18.BREuronext BrusselsBelgium
ABI.BR2021-01-06 00:00:00Z58.77.BREuronext BrusselsBelgium
ABI.BR2021-01-07 00:00:00Z58.4.BREuronext BrusselsBelgium
ABI.BR2021-01-08 00:00:00Z57.86.BREuronext BrusselsBelgium

Left Join

Polars.NET | Left join

JoinType.Left keeps all rows from the left frame. Unmatched rows on the right produce null in the new columns. Use .NullCount on the joined column to verify how many rows had no match.

Joins OHLCV to a 3-row partial dimension table (only .DE, .PA, .AS), keeping all 66355 rows and producing 15889 null exchange_name entries for the unmatched .BR, .MC, .MI, .HE suffixes.

// Polars.NET — Left join with partial dim table to demonstrate nulls
// Only include 3 of the 7 exchanges so some rows have no match
var dimPartial = new DataFrame(new Polars.CSharp.Series[]
{
    Polars.CSharp.Series.From("suffix", new[] { ".DE", ".PA", ".AS" }),
    Polars.CSharp.Series.From("exchange_name", new[] { "XETRA Frankfurt", "Euronext Paris", "Euronext Amsterdam" })
});
 
var leftP = dfPWithSuffix.Join(dimPartial,
    new[] { Col("suffix") }, new[] { Col("suffix") },
    JoinType.Left);
 
display($"Left join shape: {leftP.Shape}");
var exchCol = leftP.Column("exchange_name");
display($"Null exchange_name count: {exchCol.NullCount} (unmatched .BR, .MC, .MI, .HE)");
 
// Show one row per symbol to see both matched and unmatched
leftP.GroupBy("symbol").Agg(Col("suffix").First().Alias("suffix"), Col("exchange_name").First().Alias("exchange_name"))
    .Sort("suffix").Head(10)

Left join shape: (66355, 14)

Null exchange_name count: 15889 (unmatched .BR, .MC, .MI, .HE)

symbolsuffixexchange_name
AD.AS.ASEuronext Amsterdam
ADYEN.AS.ASEuronext Amsterdam
ASML.AS.ASEuronext Amsterdam
INGA.AS.ASEuronext Amsterdam
PRX.AS.ASEuronext Amsterdam

Microsoft.Data.Analysis | Left join

Left joins in MDA use the same Merge primitive with a different join algorithm. This keeps unmatched left rows but materializes right-side nulls directly into the result, making row-count checks and null auditing critical after the merge.

Left-joins a partial suffix dimension, counts the 15,889 unmatched rows, and previews representative symbols with and without a match.

// MDA — Left join with partial dimM table
var dimPartialM = new MDA.DataFrame(
    new MDA.StringDataFrameColumn("suffix", new[] { ".DE", ".PA", ".AS" }),
    new MDA.StringDataFrameColumn("exchange_name", new[] { "XETRA Frankfurt", "Euronext Paris", "Euronext Amsterdam" })
);
 
var leftDfM = dfWithSuffixM.Merge(dimPartialM, new[] { "suffix" }, new[] { "suffix" }, joinAlgorithm: MDA.JoinAlgorithm.Left);
display($"Left join shape: ({leftDfM.Rows.Count}, {leftDfM.Columns.Count})");
 
var nullCountM = 0;
var leftExchM = leftDfM.Columns["exchange_name"];
for(long i = 0; i < leftDfM.Rows.Count; i++) if (leftExchM[i] == null) nullCountM++;
 
display($"Null exchange_name count: {nullCountM} (unmatched .BR, .MC, .MI, .HE)");
 
// Unique symbols demonstration
var displayedSymsM = new HashSet<string>();
var partialShowMaskM = new MDA.PrimitiveDataFrameColumn<bool>("mask", leftDfM.Rows.Count);
for(long i = 0; i < leftDfM.Rows.Count; i++)
{
    var s = leftDfM.Columns["symbol"][i]?.ToString();
    if(s != null && !displayedSymsM.Contains(s))
    {
        displayedSymsM.Add(s);
        partialShowMaskM[i] = true;
    }
}
 
var uniqueLeftM = leftDfM.Filter(partialShowMaskM).OrderBy("suffix_left"); // Use suffix_left here
 
var cleanSuffixLeftM = uniqueLeftM.Columns["suffix_left"].Clone();
cleanSuffixLeftM.SetName("suffix");
 
new MDA.DataFrame(uniqueLeftM.Columns["symbol"], cleanSuffixLeftM, uniqueLeftM.Columns["exchange_name"]).Head(10)
Left join shape: (66355, 15)
Null exchange_name count: 15889 (unmatched .BR, .MC, .MI, .HE)
symbolsuffixexchange_name
WKL.AS.ASEuronext Amsterdam
AD.AS.ASEuronext Amsterdam
PRX.AS.ASEuronext Amsterdam
ADYEN.AS.ASEuronext Amsterdam
INGA.AS.ASEuronext Amsterdam

Anti Join

Polars.NET | Anti join

JoinType.Anti returns only the rows from the left frame whose key has no match in the right frame — the inverse of an inner join. Useful for finding data gaps: “which symbols have no entry in the dimension table?”

Returns the 15889 OHLCV rows whose suffix is not in the 3-entry partial dimension table (.DE, .PA, .AS only), isolating .BR, .HE, .MI, .MC as unmatched suffixes.

// Polars.NET — Anti join: rows whose suffix is NOT in the partial dim table
// dimPartial only has .DE, .PA, .AS — so .BR, .MC, .MI, .HE rows are returned
var antiP = dfPWithSuffix.Join(dimPartial,
    new[] { Col("suffix") }, new[] { Col("suffix") },
    JoinType.Anti);
 
display($"Anti join shape: {antiP.Shape} (rows without .DE, .PA, .AS)");
var unmatchedSuffixes = string.Join(", ", antiP.Column("suffix").Unique().ToArray<string>());
display($"Unique unmatched suffixes: {unmatchedSuffixes}");
antiP.Select("symbol", "date", "suffix").Head(8)

Anti join shape: (15889, 13) (rows without .DE, .PA, .AS)

Unique unmatched suffixes: .BR, .HE, .MI, .MC

symboldatesuffix
ABI.BR2021-01-04.BR
ABI.BR2021-01-05.BR
ABI.BR2021-01-06.BR
ABI.BR2021-01-07.BR
ABI.BR2021-01-08.BR

Microsoft.Data.Analysis | Anti join equivalent

MDA has no dedicated anti-join operator in the dataframe API used here, so the practical pattern is a left join followed by a null filter on the right-side enrichment column. This mirrors how engineers often prototype anti joins in SQL before tightening them into a dedicated ANTI or NOT EXISTS plan.

Filters the left-join result to rows with null exchange_name, confirms the 15,889 unmatched rows, and previews the unmatched suffixes.

// MDA — Anti join equivalent (Left join + filter where right is null)
var antiMaskM = new MDA.PrimitiveDataFrameColumn<bool>("mask", leftDfM.Rows.Count);
for(long i = 0; i < leftDfM.Rows.Count; i++) antiMaskM[i] = leftDfM.Columns["exchange_name"][i] == null;
var antiDfM = leftDfM.Filter(antiMaskM);
 
display($"Anti join shape: ({antiDfM.Rows.Count}, {antiDfM.Columns.Count}) (rows without .DE, .PA, .AS)");
 
// Extract unique suffixes using suffix_left
var unqSuffixesM = antiDfM.Columns["suffix_left"].Cast<string>().Where(x => x != null).Distinct().ToList();
display($"Unique unmatched suffixes: {string.Join(", ", unqSuffixesM)}");
 
var cleanSuffixAntiM = antiDfM.Columns["suffix_left"].Clone();
cleanSuffixAntiM.SetName("suffix");
 
new MDA.DataFrame(antiDfM.Columns["symbol"], antiDfM.Columns["date"], cleanSuffixAntiM).Head(8)
Anti join shape: (15889, 15) (rows without .DE, .PA, .AS)
Unique unmatched suffixes: .BR, .MC, .MI, .HE
symboldatesuffix
ABI.BR2021-01-04 00:00:00Z.BR
ABI.BR2021-01-05 00:00:00Z.BR
ABI.BR2021-01-06 00:00:00Z.BR
ABI.BR2021-01-07 00:00:00Z.BR
ABI.BR2021-01-08 00:00:00Z.BR

Semi Join

Polars.NET | Semi join

JoinType.Semi returns only the rows from the left frame whose key has a match in the right frame — but without adding any columns from the right. Use it to filter a large frame down to rows that exist in a reference set.

Semi join is a pure existence filter

A semi join keeps only the left-side rows whose keys exist on the right and does not project right-side columns. In MDA, the same idea is usually implemented with a HashSet<T>-backed mask; in SQL, use EXISTS or IN when the data is still remote.

Filters the 66K-row OHLCV frame to rows whose suffix matches one of the 7 entries in the full exchange dimension, keeping all 66355 rows and no right-side columns — confirming no rows are dropped when all suffixes match.

// Polars.NET — Semi join: keep OHLCV rows whose suffix is in the dimension table
// (all 7 suffixes are present, so result matches full frame)
var semiP = dfPWithSuffix.Join(dimExP,
    new[] { Col("suffix") }, new[] { Col("suffix") },
    JoinType.Semi);
 
display($"Semi join shape: {semiP.Shape}  (original: {dfPWithSuffix.Shape})");
semiP.Select("symbol", "date", "suffix").Head(5)

Semi join shape: (66355, 13) (original: (66355, 13))

symboldatesuffix
ABI.BR2021-01-04.BR
ABI.BR2021-01-05.BR
ABI.BR2021-01-06.BR
ABI.BR2021-01-07.BR
ABI.BR2021-01-08.BR

Microsoft.Data.Analysis | Semi join via HashSet-backed filter

MDA does not expose a dedicated semi-join algorithm in the same way Polars exposes how: Semi. The normal in-process pattern is to collect the right-side keys into a HashSet<T>, build a boolean mask over the left frame, and filter the left rows while keeping only left-side columns.

Builds a HashSet<string> from the 7-row exchange dimension, applies a boolean mask across the 66K-row OHLCV frame, and confirms that all 66355 rows survive because every suffix exists in the reference set.

// MDA — Semi join via HashSet-backed filter
var validSuffixesM = dimExM.Columns["suffix"].Cast<string>().Where(x => x != null).ToHashSet();
var semiMaskM = new MDA.PrimitiveDataFrameColumn<bool>("mask", dfWithSuffixM.Rows.Count);
 
for(long i = 0; i < dfWithSuffixM.Rows.Count; i++)
{
    semiMaskM[i] = validSuffixesM.Contains(dfWithSuffixM.Columns["suffix"][i]?.ToString());
}
 
var semiDfM = dfWithSuffixM.Filter(semiMaskM);
display($"Semi join shape: ({semiDfM.Rows.Count}, {semiDfM.Columns.Count})  (original: ({dfWithSuffixM.Rows.Count}, {dfWithSuffixM.Columns.Count}))");
 
new MDA.DataFrame(semiDfM.Columns["symbol"], semiDfM.Columns["date"], semiDfM.Columns["suffix"]).Head(5)
Semi join shape: (66355, 13)  (original: (66355, 13))

Prefer semi joins as predicates, not enrichment joins

A semi join answers “does a match exist?” and should usually avoid materializing right-side payload columns. When the right frame is large, a full merge just to discard the right columns is unnecessary memory work.

Cross Join

Polars.NET | Cross join

JoinType.Cross produces the Cartesian product of two frames: every row on the left is paired with every row on the right. Result row count = left.rows × right.rows. Use for generating all combinations of two small sets.

Cross join row explosion

A cross join of two 1,000-row frames produces 1,000,000 rows. Never cross-join large frames without filtering or limiting both sides first. Always verify result.Shape before using the output.

Cross join is a deliberate Cartesian product

MDA has no dedicated cross-join helper in this notebook workflow. If you need the same behavior, build it explicitly and keep both sides tiny so the multiplicative row growth stays controlled.

Cross-joins a 2-symbol frame (ASML.AS, MC.PA) with a 2-value exchange frame (Primary, Secondary), producing all 4 symbol × exchange combinations — demonstrating the Cartesian product behavior on a minimal example.

// Polars.NET — Cross join: all symbol × suffix combinations (tiny example)
var syms = new DataFrame(new Polars.CSharp.Series[]
{
    Polars.CSharp.Series.From("symbol", new[] { "ASML.AS", "MC.PA" })
});
var exs = new DataFrame(new Polars.CSharp.Series[]
{
    Polars.CSharp.Series.From("exchange", new[] { "Primary", "Secondary" })
});
var crossP = syms.Join(exs, Array.Empty<Polars.CSharp.Series>(), Array.Empty<Polars.CSharp.Series>(), JoinType.Cross);
display($"Cross join shape: {crossP.Shape}");
crossP

Cross join shape: (4, 2)

symbolexchange
ASML.ASPrimary
ASML.ASSecondary
MC.PAPrimary
MC.PASecondary

Microsoft.Data.Analysis | Cross join via explicit Cartesian construction

MDA has no dedicated cross-join helper in this chapter’s workflow. If you genuinely need a Cartesian product, build it explicitly with nested loops or by broadcasting a tiny right-side lookup into repeated rows, and do it only after aggressive filtering.

Builds the same 2-symbol by 2-exchange grid explicitly in MDA, materializes the four Cartesian pairs, and confirms the expected (4, 2) result.

// MDA — Cross join via explicit Cartesian construction
var symbolGridM = new[] { "ASML.AS", "MC.PA" };
var exchangeGridM = new[] { "Primary", "Secondary" };
var crossPairsM = new List<(string symbol, string exchange)>();
 
foreach(var symbol in symbolGridM)
{
    foreach(var exchange in exchangeGridM)
    {
        crossPairsM.Add((symbol, exchange));
    }
}
 
var crossDfM = new MDA.DataFrame(
    new MDA.StringDataFrameColumn("symbol", crossPairsM.Select(p => p.symbol)),
    new MDA.StringDataFrameColumn("exchange", crossPairsM.Select(p => p.exchange))
);
 
display($"Cross join shape: ({crossDfM.Rows.Count}, {crossDfM.Columns.Count})");
crossDfM
Cross join shape: (4, 2)

Cross joins amplify row counts multiplicatively

A 10,000 x 1,000 Cartesian product creates 10 million rows before any downstream transform. This is a memory and notebook-responsiveness risk in both libraries.

[!success] Keep cross joins tiny or move them upstream

Filter both sides first, project only the needed columns, and prefer upstream execution when the product is larger than a small exploratory or feature-grid workload.


Concatenation

Vertical Concatenation

Polars.NET | Vertical concatenation

.VStack(other) stacks two frames with the same schema vertically (adds rows). Both frames must have identical column names and types — Polars.NET raises an error on schema mismatch, preventing silent data corruption.

Splits the first 20 OHLCV rows into two 10-row slices and recombines them with VStack, confirming the result is (20, 12) — both slices share the identical 12-column schema.

// Polars.NET — Split first 10 and next 10, then vertical concat
var topP = dfP.Head(10);
var botP = dfP.Slice(10, 10);
var vcatP = topP.VStack(botP);
 
display($"Top: {topP.Shape}  Bot: {botP.Shape}  VStack: {vcatP.Shape}");
vcatP.Head(5)

Top: (10, 12) Bot: (10, 12) VStack: (20, 12)

idsymboldateopenhighlowcloseadj_closevolumedividendsstock_splitsis_filled
21160ABI.BR2021-01-0458.1558.8556.7857.2153.5761151393700false
21161ABI.BR2021-01-0556.957.9856.7557.1853.548138272200false
21162ABI.BR2021-01-0657.9658.9457.3958.7755.037137020400false
21163ABI.BR2021-01-0758.6858.8657.8858.454.6905146991100false
21164ABI.BR2021-01-0858.1658.457.4357.8654.1848142868100false

Microsoft.Data.Analysis | Vertical concatenation

MDA does not expose a Polars-style VStack convenience, but it can append rows in place once the schema is aligned. This is workable for notebook-sized reconstruction and batch assembly tasks, though repeated row appends are not the pattern to choose for very large concatenation pipelines.

Splits the first 20 OHLCV rows into two 10-row segments, appends the second segment into a cloned first segment, and verifies the resulting 20-row frame.

// MDA — Split first 10 and next 10, then vertical concat (VStack)
var topM = dfM.Head(10);
 
var botMaskM = new MDA.PrimitiveDataFrameColumn<bool>("mask", dfM.Rows.Count);
for(long i = 10; i < 20 && i < dfM.Rows.Count; i++) botMaskM[i] = true;
var botM = dfM.Filter(botMaskM);
 
var vcatM = topM.Clone();
for(long i = 0; i < botM.Rows.Count; i++)
{
    var rowVals = new List<KeyValuePair<string, object>>();
    foreach(var c in botM.Columns) rowVals.Add(new KeyValuePair<string, object>(c.Name, c[i]));
    vcatM.Append(rowVals, inPlace: true);
}
 
display($"Top: ({topM.Rows.Count}, {topM.Columns.Count})  Bot: ({botM.Rows.Count}, {botM.Columns.Count})  Concat: ({vcatM.Rows.Count}, {vcatM.Columns.Count})");
vcatM.Head(5)
Top: (10, 12)  Bot: (10, 12)  Concat: (20, 12)
idsymboldateopenhighlowcloseadj_closevolumedividendsstock_splitsis_filled
21160ABI.BR2021-01-04 00:00:00Z58.1558.8556.7857.2153.5761151393700False
21161ABI.BR2021-01-05 00:00:00Z56.957.9856.7557.1853.548138272200False
21162ABI.BR2021-01-06 00:00:00Z57.9658.9457.3958.7755.037137020400False
21163ABI.BR2021-01-07 00:00:00Z58.6858.8657.8858.454.6905146991100False
21164ABI.BR2021-01-08 00:00:00Z58.1658.457.4357.8654.1848142868100False

Horizontal Concatenation

Polars.NET | Horizontal concatenation

.HStack(series) appends a single Series as a new column. To add multiple columns from another frame, call .HStack() once per column. Both frames must have the same number of rows.

Splits the first 5 OHLCV rows into a 3-column left frame and a 3-column right frame, then rebuilds a (5, 6) frame via three sequential HStack calls — adding volume, high, and low one column at a time.

// Polars.NET — Horizontal concat: split columns, then rejoin
var leftCols = dfP.Select(Col("symbol"), Col("date"), Col("close")).Head(5);
var rightCols = dfP.Select(Col("volume"), Col("high"), Col("low")).Head(5);
 
// HStack adds series; extract each column from right and stack
var hcatP = leftCols
    .HStack(rightCols.Column("volume"))
    .HStack(rightCols.Column("high"))
    .HStack(rightCols.Column("low"));
 
display($"Left: {leftCols.Shape}  Right: {rightCols.Shape}  HStacked: {hcatP.Shape}");
hcatP

Left: (5, 3) Right: (5, 3) HStacked: (5, 6)

symboldateclosevolumehighlow
ABI.BR2021-01-0457.21151393758.8556.78
ABI.BR2021-01-0557.18138272257.9856.75
ABI.BR2021-01-0658.77137020458.9457.39
ABI.BR2021-01-0758.4146991158.8657.88
ABI.BR2021-01-0857.86142868158.457.43

Microsoft.Data.Analysis | Horizontal concatenation

Horizontal combination in MDA is schema-first rather than key-aware by default. If two frames already have the same row alignment, columns can simply be appended; if alignment depends on keys, use a join instead of column stacking.

Clones a 3-column left frame, appends three more columns from a right frame with matching row counts, and confirms the resulting 5-row, 6-column shape.

// MDA — Horizontal concat (HStack equivalent)
var leftColsM = new MDA.DataFrame(dfM.Columns["symbol"], dfM.Columns["date"], dfM.Columns["close"]).Head(5);
var rightColsM = new MDA.DataFrame(dfM.Columns["volume"], dfM.Columns["high"], dfM.Columns["low"]).Head(5);
 
var hcatM = leftColsM.Clone();
foreach(var c in rightColsM.Columns) hcatM.Columns.Add(c);
 
display($"Left: ({leftColsM.Rows.Count}, {leftColsM.Columns.Count})  Right: ({rightColsM.Rows.Count}, {rightColsM.Columns.Count})  HStacked: ({hcatM.Rows.Count}, {hcatM.Columns.Count})");
hcatM
Left: (5, 3)  Right: (5, 3)  HStacked: (5, 6)
symboldateclosevolumehighlow
ABI.BR2021-01-04 00:00:00Z57.21151393758.8556.78
ABI.BR2021-01-05 00:00:00Z57.18138272257.9856.75
ABI.BR2021-01-06 00:00:00Z58.77137020458.9457.39
ABI.BR2021-01-07 00:00:00Z58.4146991158.8657.88
ABI.BR2021-01-08 00:00:00Z57.86142868158.457.43

Reshaping

Pivot and melt are often presentation or feature-construction steps rather than core storage layouts. Wide pivots can explode column counts, while unpivot multiplies row counts. The safest pattern is to filter and aggregate first, then reshape only the subset that genuinely needs a wide report matrix or a long modeling layout.

Pivot (Long to Wide)

Polars.NET | Pivot (long to wide)

.Pivot(columnSelector, indexSelector, valueSelector) rotates a long frame to wide format: unique values in the column selector become new column headers. Use when you need one row per date and one column per symbol.

Pivots 30 rows of close prices for ASML.AS, SAP.DE, and MC.PA from long format into a (1, 31)-shaped frame — one row per symbol with each of the 30 dates as a separate column header.

// Polars.NET — Pivot: daily close prices with symbols as columns
var filterSyms = Polars.CSharp.Series.From("s", new[] { "SAP.DE", "ASML.AS", "MC.PA" });
var pivotSubsetP = dfP
    .Filter(Col("symbol").IsIn(Lit(filterSyms)))
    .Select("date", "symbol", "close")
    .Head(30);
 
display($"Subset: {pivotSubsetP.Shape}");
var pivotP = pivotSubsetP.Pivot(
    Selector.Cols("symbol"),
    Selector.Cols("date"),
    Selector.Cols("close"));
display($"Pivot shape: {pivotP.Shape}");
pivotP.Head(10)

Subset: (30, 3)

Pivot shape: (1, 31)

symbol2021-01-042021-01-052021-01-062021-01-072021-01-082021-01-112021-01-122021-01-132021-01-142021-01-152021-01-182021-01-192021-01-202021-01-212021-01-222021-01-252021-01-262021-01-272021-01-282021-01-292021-02-012021-02-022021-02-032021-02-042021-02-052021-02-082021-02-092021-02-102021-02-112021-02-12
ASML.AS406.25406.9402.85403.9416.05414.9418.95422.45447.35435.85437.6439.9453.15470.55462.9461.35458.55440.65449439.45454.9457.5457.15459.55460467.1469.75464.1480.45494.75

Microsoft.Data.Analysis | Pivot (long to wide)

MDA has no single-call pivot API in this notebook workflow, so pivoting means explicitly enumerating the unique row and column keys, creating the wide schema, and populating the matrix cell by cell. That is acceptable for controlled reporting subsets, but it is not the reshape you want to improvise over high-cardinality columns.

Filters 30 rows for three symbols, dynamically constructs a wide dataframe with one symbol row and date columns, confirms the pivot shape, and previews the wide result.

// MDA — Pivot: daily close prices with symbols as columns
var filterSymsM = new[] { "SAP.DE", "ASML.AS", "MC.PA" };
var pivotMaskM = new MDA.PrimitiveDataFrameColumn<bool>("mask", dfM.Rows.Count);
int addedM = 0;
for(long i = 0; i < dfM.Rows.Count && addedM < 30; i++)
{
    var s = symColM[i]?.ToString();
    if(s != null && filterSymsM.Contains(s))
    {
        pivotMaskM[i] = true;
        addedM++;
    }
}
 
var pivotSubsetM = dfM.Filter(pivotMaskM);
var finalSubsetM = new MDA.DataFrame(pivotSubsetM.Columns["symbol"], pivotSubsetM.Columns["date"], pivotSubsetM.Columns["close"]);
display($"Subset: ({finalSubsetM.Rows.Count}, {finalSubsetM.Columns.Count})");
 
// Extract distinct pivot values
var pivotSymsM = finalSubsetM.Columns["symbol"].Cast<string>().Distinct().ToList();
var pivotDatesM = finalSubsetM.Columns["date"].Cast<DateTime?>().Distinct().OrderBy(d => d).ToList();
 
// Build dynamically pivoted columns
var pivotColsM = new List<MDA.DataFrameColumn>();
 
// Initialize the String column safely using the explicit length
var symColumnM = new MDA.StringDataFrameColumn("symbol", pivotSymsM.Count);
for(int i = 0; i < pivotSymsM.Count; i++) symColumnM[i] = pivotSymsM[i];
pivotColsM.Add(symColumnM);
 
foreach(var d in pivotDatesM)
{
    pivotColsM.Add(new MDA.PrimitiveDataFrameColumn<double>(d?.ToString("yyyy-MM-dd"), pivotSymsM.Count));
}
var pivotDfM = new MDA.DataFrame(pivotColsM);
 
// Populate matrix
for(long i = 0; i < finalSubsetM.Rows.Count; i++)
{
    var s = finalSubsetM.Columns["symbol"][i]?.ToString();
    var d = ((DateTime?)finalSubsetM.Columns["date"][i])?.ToString("yyyy-MM-dd");
    var c = Convert.ToDouble(finalSubsetM.Columns["close"][i]);
 
    int rIdx = pivotSymsM.IndexOf(s);
    if(rIdx >= 0 && d != null) pivotDfM.Columns[d][rIdx] = c;
}
 
display($"Pivot shape: ({pivotDfM.Rows.Count}, {pivotDfM.Columns.Count})");
 
// FIXED: Manually clamp the Head request to avoid MDA's out-of-bounds bug
int headCountM = (int)Math.Min(10, pivotDfM.Rows.Count);
pivotDfM.Head(headCountM)
Subset: (30, 3)
Pivot shape: (1, 31)
symbol2021-01-042021-01-052021-01-062021-01-072021-01-082021-01-112021-01-122021-01-132021-01-142021-01-152021-01-182021-01-192021-01-202021-01-212021-01-222021-01-252021-01-262021-01-272021-01-282021-01-292021-02-012021-02-022021-02-032021-02-042021-02-052021-02-082021-02-092021-02-102021-02-112021-02-12
ASML.AS406.25406.8999938964844402.8500061035156403.8999938964844416.04998779296875414.8999938964844418.95001220703125422.45001220703125447.3500061035156435.8500061035156437.6000061035156439.8999938964844453.1499938964844470.54998779296875462.8999938964844461.3500061035156458.54998779296875440.6499938964844449439.45001220703125454.8999938964844457.5457.1499938964844459.54998779296875460467.1000061035156469.75464.1000061035156480.45001220703125494.75

Unpivot (Wide to Long)

Polars.NET | Unpivot (wide to long)

.Unpivot(on, index) is the inverse of pivot: the columns named in on become rows in a new variable column, with their values in a value column. The index columns are preserved as-is per row. Result shape: n_rows × len(on) rows.

Melts the 4 OHLC columns of the first 5 OHLCV rows from wide to long format, expanding (5, 6) into (20, 4) — with variable cycling through open, high, low, close and value holding the corresponding price.

// Polars.NET — Melt/Unpivot: turn OHLC columns into rows
var ohlcSubset = dfP
    .Select(Col("symbol"), Col("date"), Col("open"), Col("high"), Col("low"), Col("close"))
    .Head(5);
 
var meltedP = ohlcSubset.Unpivot(
    on: new[] { "open", "high", "low", "close" },
    index: new[] { "symbol", "date" }
);
 
display($"Melted shape: {meltedP.Shape}");
meltedP.Head(12)

Melted shape: (20, 4)

symboldatevariablevalue
ABI.BR2021-01-04open58.15
ABI.BR2021-01-05open56.9
ABI.BR2021-01-06open57.96
ABI.BR2021-01-07open58.68
ABI.BR2021-01-08open58.16

Microsoft.Data.Analysis | Unpivot (wide to long)

Unpivot in MDA is the inverse manual process: iterate the measure columns, emit one output row per original value, and materialize the long-form result into typed columns. This pattern is common when preparing features for charting, model input, or uniform rule evaluation.

Takes a 5-row OHLC subset, emits one row per open, high, low, and close value, and materializes the expected 20-row long dataframe.

// MDA — Melt/Unpivot: turn OHLC columns into rows
var ohlcSubsetM = new MDA.DataFrame(dfM.Columns["symbol"], dfM.Columns["date"], dfM.Columns["open"], dfM.Columns["high"], dfM.Columns["low"], dfM.Columns["close"]).Head(5);
 
var varsM = new[] { "open", "high", "low", "close" };
// FIXED: Changed int to long
long meltedCountM = ohlcSubsetM.Rows.Count * varsM.Length;
 
var meltSymM = new MDA.StringDataFrameColumn("symbol", meltedCountM);
var meltDateM = new MDA.PrimitiveDataFrameColumn<DateTime>("date", meltedCountM);
var meltVarM = new MDA.StringDataFrameColumn("variable", meltedCountM);
var meltValM = new MDA.PrimitiveDataFrameColumn<double>("value", meltedCountM);
 
long mIdxM = 0; // FIXED: Consistent with long indexing
foreach(var v in varsM)
{
    for(long i = 0; i < ohlcSubsetM.Rows.Count; i++)
    {
        meltSymM[mIdxM] = ohlcSubsetM.Columns["symbol"][i]?.ToString();
        if(ohlcSubsetM.Columns["date"][i] is DateTime dt) meltDateM[mIdxM] = dt;
        meltVarM[mIdxM] = v;
        meltValM[mIdxM] = Convert.ToDouble(ohlcSubsetM.Columns[v][i]);
        mIdxM++;
    }
}
 
var meltedDfM = new MDA.DataFrame(meltSymM, meltDateM, meltVarM, meltValM);
display($"Melted shape: ({meltedDfM.Rows.Count}, {meltedDfM.Columns.Count})");
meltedDfM.Head(12)
Melted shape: (20, 4)
symboldatevariablevalue
ABI.BR2021-01-04 00:00:00Zopen58.150001525878906
ABI.BR2021-01-05 00:00:00Zopen56.900001525878906
ABI.BR2021-01-06 00:00:00Zopen57.959999084472656
ABI.BR2021-01-07 00:00:00Zopen58.68000030517578
ABI.BR2021-01-08 00:00:00Zopen58.15999984741211

Summary Comparison

OperationPolars.NETMicrosoft.Data.Analysis
GroupBy + single agg.GroupBy("col").Agg(Col("x").Mean())Manual accumulator dictionary, then materialize grouped result frame
GroupBy + multi agg.GroupBy().Agg(sum, mean, count, ...) in one callSingle explicit pass is possible, but you manage accumulator state and output schema
GroupBy multiple cols.GroupBy("a", "b")Tuple-key dictionary aggregation
Group headExpression workaround or grouped row-numbering patternManual row numbering plus boolean mask
Window: mean overCol("x").Mean().Over("g")Precompute group means, then broadcast via second pass
Window: rankCol("x").Rank().Over("g")Sort indices within each group and assign ordinal ranks explicitly
Window: rollingCol("x").RollingMean("20i").Over("g")Manual trailing-window loop per group
Inner / left join.Join(..., how: Inner/Left)Merge(..., joinAlgorithm: ...)
Anti join.Join(..., how: Anti)Left merge plus null filter on right-side columns
Semi join.Join(..., how: Semi)HashSet-backed filter pattern
Cross join.Join(..., how: Cross)Manual Cartesian construction only for tiny sets
Vertical concat.VStack(other)Clone and Append(..., inPlace: true) row by row
Horizontal concat.HStack(series) / select-then-stackAppend aligned columns directly; use Merge when alignment is key-based
Pivot.Pivot(...)Build the wide schema and populate cells manually
Unpivot / Melt.Unpivot(on, index)Emit long-form rows manually and materialize typed result columns

Which library should own aggregation and reshape work?

Prefer Polars.NET when the main job is analytical transformation: many grouped metrics, chained windows, repeated joins, reshape-heavy notebook work, or pipelines that benefit from a compact expression API and a clearer transformation graph.

Prefer Microsoft.Data.Analysis when the dataframe is one in-process component inside a broader .NET application: custom CLR logic, typed column control, ML.NET-adjacent preparation, or explicit notebook demonstrations where transparency matters more than terse syntax.

Prefer neither for warehouse-scale joins, large rollups, or fact-to-fact windows if the data is still remote. Push those operations upstream into SQL, Spark, DuckDB, or the warehouse engine and use Polars or MDA after extraction for local enrichment, QA, feature prep, or presentation reshapes.

Quote

Assemble pipelines as isolated, reusable transformations and let the right execution engine own the expensive stage.

Source: Eberhard Wolff | Data Management at Scale Modern Data Architecture with Data Mesh and Data Fabric - 2nd Edition.pdf


Operational Risks

API Semantics

Reassign Filter() and Sort() results in Polars.NET

Polars.NET transforms return a new dataframe. If you call Filter() or Sort() and discard the returned frame, the original stays unchanged.

Runs a minimal reassignment contrast and prints the retained and transformed values.

var originalValues = new[] { 1, 2, 3 };
var transformedValues = originalValues.Select(x => x * 10).ToArray();
 
Console.WriteLine($"Original: {string.Join(", ", originalValues)}");
Console.WriteLine($"Transformed: {string.Join(", ", transformedValues)}");
Original: 1, 2, 3
Transformed: 10, 20, 30

Keep IfElse() syntax distinct from When().Then().Otherwise()

Treat IfElse() as the C# binding surface rather than assuming the Python when/then/otherwise chain exists unchanged.

Runs a minimal branch and prints the selected value for an IfElse()-style condition.

var x = 4;
var branch = x > 0 ? "positive" : "non-positive";
 
Console.WriteLine($"Branch result: {branch}");
Branch result: positive

Schema Boundaries

Map Arrow-style and CLR types explicitly

Polars.NET exposes Arrow-oriented types while Microsoft.Data.Analysis uses CLR-backed DataFrameColumn implementations. Crossing that boundary without an explicit mapping invites schema drift.

Prints a simple type map for a common numeric handoff.

var polarsType = "Float64";
var mdaType = "DoubleDataFrameColumn";
 
Console.WriteLine($"Map {polarsType} -> {mdaType}");
Map Float64 -> DoubleDataFrameColumn

Transformation Ownership

Keep reshape-heavy work in Polars.NET

Use GroupBy(), .Over(), JoinType.Semi, and Pivot() in Polars.NET when the transformation graph itself is the main deliverable.

Runs a simple routing rule that sends reshape-heavy workloads to the Polars branch.

var workload = "reshape-heavy";
var engineForTransforms = workload == "reshape-heavy" ? "Polars.NET" : "Microsoft.Data.Analysis";
 
Console.WriteLine($"Recommended engine: {engineForTransforms}");
Recommended engine: Polars.NET

Use Microsoft.Data.Analysis at IDataView boundaries

Keep Microsoft.Data.Analysis when the dataframe is an in-process staging object for CLR-heavy code or downstream IDataView consumers.

Runs a simple routing rule for an IDataView-style handoff.

var target = "IDataView";
var engineForBoundary = target == "IDataView" ? "Microsoft.Data.Analysis" : "Polars.NET";
 
Console.WriteLine($"Recommended engine: {engineForBoundary}");
Recommended engine: Microsoft.Data.Analysis

Contract Checks

Assert schema after GroupBy() or Pivot()

After GroupBy() or Pivot(), validate the resulting column contract before feeding the output into later joins, exports, or model code.

Builds a minimal expected-schema check and prints whether the contract matches.

var expectedColumns = new[] { "symbol", "avg_close" };
var actualColumns = new[] { "symbol", "avg_close" };
var schemaMatches = expectedColumns.SequenceEqual(actualColumns);
 
Console.WriteLine($"Schema matches: {schemaMatches}");
Schema matches: True

C# Aggregation and Reshaping Troubleshooting

Failure Modes

Unchanged result after WithColumns()

If a Polars transform appears unchanged, confirm you kept the returned frame rather than discarding the result of WithColumns().

Runs a minimal before-and-after check that prints the original and reassigned values.

var baseline = new[] { 2, 4, 6 };
var reassigned = baseline.Select(x => x + 1).ToArray();
 
Console.WriteLine($"Baseline: {string.Join(", ", baseline)}");
Console.WriteLine($"Reassigned: {string.Join(", ", reassigned)}");
Baseline: 2, 4, 6
Reassigned: 3, 5, 7

ComputeError during Cast()

Cast failures usually mean at least one row cannot be converted to the requested target type. Clean or branch those rows before calling Cast().

Runs a guarded parse and prints the values that would fail a numeric cast.

var rawValues = new[] { "10", "11.5", "bad" };
var invalidValues = rawValues.Where(x => !double.TryParse(x, out _)).ToArray();
 
Console.WriteLine($"Invalid values: {string.Join(", ", invalidValues)}");
Invalid values: bad

Match the DataFrameColumn type to the CLR payload

When Microsoft.Data.Analysis column construction fails, verify that the chosen DataFrameColumn matches the CLR value type actually stored in the input.

Prints the expected column class for a simple integer payload.

var payloadType = typeof(int).Name;
var columnType = "Int32DataFrameColumn";
 
Console.WriteLine($"Payload {payloadType} -> {columnType}");
Payload Int32 -> Int32DataFrameColumn