matplotlib in C#
Install: dotnet add package MatPlotLibNet — add MatPlotLibNet.Skia as well for PNG, PDF and GIF.
If you know matplotlib and you are now writing C#, you do not need to learn a new charting library from
scratch. This library was built around matplotlib's own model — a figure holds axes, an axes holds series,
a series carries its own style — so most of what you know transfers name for name. This page is the
lookup table: the pyplot call on the left, the line that draws the same chart on the right.
Two differences are worth reading before the tables, because they explain almost every row.
There is no global current figure. In pyplot, plt.plot(...) draws on whichever figure is current, and
plt.gca() hands you the axes behind it. Here you always hold the object you are drawing on, so there is
nothing to get and nothing to close. That removes plt.gca, plt.gcf, plt.cla, plt.clf, plt.close
and plt.hold from the table: none of them has an equivalent, because none of them has anything to do.
Calls chain. Every verb returns the thing you called it on, so a chart is one expression instead of a
paragraph of statements. That is why the C# column reads ax.Plot(...).WithTitle(...) where Python reads
two lines.
The same chart, twice
# Python
import matplotlib.pyplot as plt
fig, ax = plt.subplots(figsize=(8, 6))
ax.plot(x, y, color='steelblue', linewidth=2, label='Signal')
ax.set_xlabel('Time (s)')
ax.set_ylabel('Amplitude')
ax.set_title('Measurement')
ax.legend()
ax.grid(True)
fig.savefig('chart.png', dpi=150)
// C#
using MatPlotLibNet;
using MatPlotLibNet.Styling;
Plt.Create()
.WithSize(800, 600) // in pixels at 96 dpi
.WithDpi(150) // the PNG is drawn at 150 dpi: 1250 x 938 pixels, the same layout drawn finer
.AddSubPlot(1, 1, 1, ax => ax
.Plot(x, y, s => { s.Color = Colors.SteelBlue; s.LineWidth = 2; s.Label = "Signal"; })
.SetXLabel("Time (s)")
.SetYLabel("Amplitude")
.WithTitle("Measurement")
.WithLegend()
.WithGrid(g => g with { Visible = true }))
.Save("chart.png");
| matplotlib |
MatPlotLibNet |
Notes |
plt.figure() |
Plt.Create() |
Starts a figure. .Build() hands you the Figure; .Save(…) finishes it in one go. |
plt.subplots() |
figure.AddSubPlot(1, 1, 1, ax => …) |
The axes is handed to the lambda instead of returned. |
plt.subplots(2, 3) |
figure.AddSubPlot(2, 3, index, ax => …) |
Called once per panel; index runs 1…6, row by row, as in matplotlib. |
plt.figure(figsize=(8, 6)) |
figure.WithSize(800, 600) |
Pixels at 96 dpi, not inches. |
plt.figure(dpi=150) |
figure.WithDpi(150) |
A PNG, and each frame of a GIF, is drawn with dpi / 96 pixels per unit: 800 × 600 at 150 dpi is a 1250 × 938 PNG with the same layout, text and line widths. An SVG, a PDF page and JSON have no pixels, so the resolution changes none of them. |
fig.savefig('c.png', dpi=300) |
figure.Build().Save("c.png", new SaveOptions { Dpi = 300 }) |
The resolution for this one save. SaveOptions also carries an SVG's indentation and decimals, and a title and author that go into an SVG, a PNG and a PDF. |
fig.suptitle('…') |
figure.WithTitle("…") |
The figure's own title, above the panels. |
plt.tight_layout() |
figure.TightLayout() |
|
plt.subplots(constrained_layout=True) |
figure.ConstrainedLayout() |
|
gridspec.GridSpec(2, 2, height_ratios=…) |
figure.WithGridSpec(2, 2, heightRatios) |
Ratios are arrays, same meaning. |
plt.subplots_adjust(hspace=…) |
figure.WithSubPlotSpacing(sp => sp with { VerticalGap = … }) |
|
plt.subplot_mosaic('AB;CC') |
Plt.Mosaic("AB;CC") |
Same string layout language. |
fig.add_axes([…]) |
ax.AddInset(bounds, inset => …) |
An inset belongs to the axes it sits in. |
plt.savefig('c.png') |
figure.Save("c.png") |
The extension picks the format: .svg, .png, .pdf, .gif. |
fig.canvas.tostring_rgb() |
figure.ToSvg() |
The markup as a string, for a web response. |
plt.show() |
— |
Nothing renders to a screen by itself; see the Playground or the Blazor, WPF, MAUI, Avalonia and Uno controls. |
plt.gca(), plt.gcf() |
— |
There is no current figure to get. |
plt.close(), plt.clf() |
— |
Nothing is held open. |
The plots
| matplotlib |
MatPlotLibNet |
Notes |
ax.plot(x, y) |
ax.Plot(x, y) |
|
ax.scatter(x, y) |
ax.Scatter(x, y) |
|
ax.scatter(x, y, c=density) |
ax.DensityScatter(x, y) |
Counts the points per cell and colours by it; no density array to compute. |
ax.bar(labels, heights) |
ax.Bar(labels, heights) |
|
ax.bar with an offset per group |
ax.GroupedBar(categories, groups) |
The offset arithmetic every grouped bar chart repeats is done for you. |
ax.barh(labels, widths) |
ax.Bar(labels, values, s => s.Orientation = BarOrientation.Horizontal) |
One series flag rather than a second verb. |
ax.step(x, y) |
ax.Step(x, y) |
|
ax.stem(x, y) |
ax.Stem(x, y) |
|
ax.stackplot(x, ys) |
ax.StackPlot(x, ySets) |
|
ax.fill_between(x, y1, y2) |
ax.FillBetween(x, y, y2) |
|
ax.errorbar(x, y, yerr=e) |
ax.ErrorBar(x, y, errorLow, errorHigh) |
|
ax.pie(sizes, labels=…) |
ax.Pie(sizes, labels) |
|
donut via wedgeprops |
ax.Donut(sizes, labels) |
|
ax.eventplot(positions) |
ax.Eventplot(positions) |
|
ax.table(cellText=…) |
ax.Table(cells) |
|
| — |
ax.Sankey(nodes, links) |
|
squarify.plot |
ax.Treemap(root) |
matplotlib has no treemap; this one is built in. |
nested ax.pie |
ax.Sunburst(root) |
|
Charts matplotlib draws from a recipe
matplotlib has no single call for these charts: they are several calls and some arithmetic, or another package.
Here each is one call that adds the series the chart is made of, so it saves, exports and reads back from JSON like
any other chart. The bar charts, distribution, line charts
and heatmaps pages show each one with its picture.
| matplotlib / Python |
MatPlotLibNet |
Notes |
ax.spy(matrix) |
ax.Spy(matrix) |
Square markers on the cells whose magnitude is above a threshold, row 0 on top. |
scipy.stats.probplot(x, plot=ax) |
ax.QQPlot(sample) |
scipy's plotting positions and least-squares reference line; normal distribution only. |
ax.stackplot(x, ys, baseline='wiggle') |
ax.StreamGraph(x, layers) |
One area per layer, each named in the legend. |
pandas.plotting.parallel_coordinates(df, 'class') |
ax.ParallelCoordinates(dimensions, rows) |
Each column scaled to 0..1; rows that share a name are one class. |
lifelines.KaplanMeierFitter().fit(t, e).plot_survival_function() |
ax.KaplanMeier(groups) |
The product-limit curve only, no band or censor marks. |
joypy.joyplot(df) |
ax.Ridgeline(groups) |
One shared grid and bandwidth, so the ridges compare. |
sorted ax.bar plus a twinx cumulative line |
ax.Pareto(categories, counts) |
Sorts, accumulates to exactly 100 % and aligns both axes. |
ax.stem over category positions |
ax.Lollipop(categories, values) |
One stem series: one colour, one legend entry. |
ax.vlines plus two ax.scatter |
ax.Dumbbell(categories, from, to) |
The connector spans the two dots whichever is higher. |
one ax.plot of two points per category |
ax.SlopeGraph(categories, from, to) |
The names at both ends, no legend. |
ax.scatter per group over category rows |
ax.DotPlot(categories, groups) |
Sorted by the first group, largest on top. |
ax.plot per group with the Y axis inverted |
ax.BumpChart(x, ranks) |
Rank 1 on top, the names at the line ends. |
ax.plot plus ax.axhline for the limits |
ax.ControlChart(x, values) |
Limits estimated from the average moving range; values beyond them marked. |
calplot / july |
ax.CalendarHeatmap(dates, values) |
Seven weekday rows by one column per week, months named. |
ax.bar with widths and bottoms per segment |
ax.Marimekko(columns, segments) |
Column widths and cell heights as shares. |
Labels, limits, scales and the frame
| matplotlib |
MatPlotLibNet |
Notes |
ax.set_xlabel('…') |
ax.SetXLabel("…") |
|
ax.set_ylabel('…') |
ax.SetYLabel("…") |
|
ax.set_title('…') |
ax.WithTitle("…") |
|
ax.set_xlim(0, 10) |
ax.SetXLim(0, 10) |
|
ax.set_ylim(0, 10) |
ax.SetYLim(0, 10) |
|
ax.set_xscale('log') |
ax.SetXScale(AxisScale.Log) |
Linear, Log, SymLog and Logit, as in matplotlib. |
ax.set_yscale('log') |
ax.SetYScale(AxisScale.Log) |
|
ax.legend() |
ax.WithLegend() |
|
ax.legend(loc='upper left') |
ax.WithLegend(LegendPosition.UpperLeft) |
|
ax.text(x[-1], y[-1], 'Web', color=line.get_color()) |
ax.WithDirectLabel() |
Names the series added last at the end of its line, in its colour, instead of in the legend. See direct labels. |
ax.grid(True) |
ax.WithGrid(g => g with { Visible = true }) |
The grid is a value you change, so one call sets colour, style and which axis at once. |
ax.minorticks_on() |
ax.WithMinorTicks() |
|
ax.margins(x=0.1) |
ax.SetXMargin(0.1) |
|
ax.margins(0) |
ax.WithTightMargins() |
|
ax.spines['top'].set_visible(False) |
ax.HideTopSpine() |
HideRightSpine() for the other one. |
ax.tick_params(axis='y', colors='C1') and ax.spines['left'].set_color('C1') |
ax.SetYAxisColor(Colors.Tab10Orange) |
One call colours the spine, the tick marks, the tick labels and the title. SetXAxisColor for X; the secondary-axis builders have the same verbs. |
ax.axhline(y=0) |
ax.AxHLine(0) |
|
ax.axvline(x=0) |
ax.AxVLine(0) |
|
ax.axhspan(1, 2) |
ax.AxHSpan(1, 2) |
|
ax.axvspan(1, 2) |
ax.AxVSpan(1, 2) |
|
ax.annotate('…', xy=…) |
ax.Annotate("…", x, y) |
Arrows, callout boxes and axes-fraction placement are all on the same call. |
ax.twinx() |
ax.WithSecondaryYAxis(right => …) |
|
ax.sharex(other) |
ax.ShareX("time") |
|
plt.xticks(rotation=45) |
ax.WithXTickLabelRotation(45) |
|
ax.xaxis.set_major_formatter(f) |
ax.SetXTickFormatter(formatter) |
|
ax.xaxis.set_major_locator(l) |
ax.SetXTickLocator(locator) |
|
mdates.DateFormatter('%H:%M') |
ax.SetXDateFormat("HH:mm") |
.NET format strings, not strftime. |
Colour and style
| matplotlib |
MatPlotLibNet |
Notes |
color='steelblue' |
Colors.SteelBlue |
All 148 CSS4 names are there. |
color='#1f77b4' |
Color.FromHex("#1f77b4") |
|
color='red' |
Color.FromName("red") |
|
linestyle='--' |
LineStyle.Dashed |
|
marker='o' |
MarkerStyle.Circle |
|
cmap='viridis' |
ColorMaps.Viridis |
148 colormaps, matplotlib's own included. |
cmap='coolwarm' |
ColorMaps.Coolwarm |
|
plt.colormaps() |
ColorMaps.All |
|
plt.style.use('dark_background') |
figure.WithTheme(Theme.Dark) |
30 themes; Theme.MatplotlibV2 is matplotlib's own default palette. |
plt.style.use('ggplot') |
figure.WithTheme(Theme.Ggplot) |
|
plt.style.use('seaborn') |
figure.WithTheme(Theme.Seaborn) |
|
mpl.rcParams[…] = … |
Theme.CreateFrom(Theme.Default) |
Build a theme from an existing one instead of mutating a global dictionary. |
fig.colorbar(im) |
ax.WithColorBar() |
|
Distributions and statistics
| matplotlib / seaborn |
MatPlotLibNet |
Notes |
ax.hist(data, bins=20) |
ax.Hist(data, 20) |
|
ax.hist2d(x, y) |
ax.Histogram2D(x, y) |
|
ax.boxplot(data) |
ax.BoxPlot(data) |
|
ax.violinplot(data) |
ax.Violin(data) |
|
ax.hexbin(x, y) |
ax.Hexbin(x, y) |
|
sns.kdeplot(data) |
ax.Kde(data) |
|
sns.rugplot(data) |
ax.Rugplot(data) |
|
sns.stripplot(…) |
ax.Stripplot(datasets) |
|
sns.swarmplot(…) |
ax.Swarmplot(datasets) |
|
sns.countplot(…) |
ax.Countplot(values) |
|
sns.pointplot(…) |
ax.Pointplot(datasets) |
|
ax.ecdf(data) |
ax.Ecdf(data) |
|
sns.heatmap(matrix) |
ax.Heatmap(matrix) |
|
sns.clustermap(matrix) |
ax.Clustermap(matrix) |
|
sns.pairplot(frame) |
ax.PairGrid(variables) |
|
scipy.cluster.hierarchy.dendrogram |
ax.Dendrogram(root) |
|
Images, grids and vector fields
| matplotlib |
MatPlotLibNet |
Notes |
ax.imshow(matrix) |
ax.Image(matrix) |
|
ax.pcolormesh(x, y, z) |
ax.Pcolormesh(xEdges, yEdges, values) |
|
ax.contour(x, y, z) |
ax.Contour(x, y, z) |
|
ax.contourf(x, y, z) |
ax.Contourf(x, y, z) |
|
ax.quiver(x, y, u, v) |
ax.Quiver(x, y, u, v) |
|
ax.quiverkey(q, …) |
ax.QuiverKey(x, y, u, "5 m/s") |
|
ax.streamplot(x, y, u, v) |
ax.Streamplot(x, y, u, v) |
|
ax.barbs(x, y, u, v) |
ax.Barbs(x, y, u, v) |
|
ax.tricontour(…) |
ax.Tricontour(x, y, z) |
|
ax.tripcolor(…) |
ax.Tripcolor(x, y, z) |
|
ax.specgram(signal) |
ax.Spectrogram(signal) |
|
Three dimensions
| matplotlib |
MatPlotLibNet |
Notes |
fig.add_subplot(projection='3d') |
ax.WithProjection(elevation, azimuth) |
The projection is a property of the axes, set where you draw. |
ax.plot_surface(x, y, z) |
ax.Surface(x, y, z) |
|
ax.plot_wireframe(x, y, z) |
ax.Wireframe(x, y, z) |
|
ax.plot_trisurf(x, y, z) |
ax.Trisurf(x, y, z) |
|
ax.scatter(x, y, z) |
ax.Scatter3D(x, y, z) |
|
ax.plot(x, y, z) |
ax.Plot3D(x, y, z) |
|
ax.contour3D(x, y, z) |
ax.Contour3D(x, y, z) |
|
ax.bar3d(…) |
ax.Bar3D(x, y, z) |
|
ax.voxels(filled) |
ax.Voxels(filled) |
|
ax.text(x, y, z, '…') |
ax.Text3D(x, y, z, "…") |
|
ax.view_init(elev, azim) |
ax.WithCamera(30, -60) |
|
Polar
| matplotlib |
MatPlotLibNet |
Notes |
plt.subplot(projection='polar') + plot |
ax.PolarPlot(r, theta) |
The polar frame comes with the series. The radius comes first; the angle is in radians, counter-clockwise from the right, as in matplotlib. |
polar scatter |
ax.PolarScatter(r, theta) |
|
polar bar |
ax.PolarBar(r, theta) |
|
| radar chart, by hand |
ax.Radar(labels, values) |
matplotlib has no radar verb; this one is built in. |
Where to go next