Tables, Charts and Exports
These methods turn data into text that people read: a table in a console or a log, a chart in a CI job, a Markdown
table for a pull request, a CSV file for a spreadsheet. Reach for them when you want a quick, readable view of a list
without pulling in a UI or charting package. Everything here returns a string; nothing writes to the console for you.
At a glance
| Method | What it does |
|---|---|
ToTable | Box-drawn text table, one column per property, numbers right-aligned. |
ToMarkdownTable | GitHub-style Markdown table, numeric columns right-aligned. |
ToHtmlTable | HTML-encoded <table> element, one column per property. |
ToTreeString | Prints a tree or a forest with branch lines, like the tree command. |
ToSparkline | [1, 5, 3, 8, 2] → "▁▅▃█▂" |
ToBarChart | Horizontal bar chart with labels and values. |
ToHistogram | Counts values in equal-width ranges and draws a bar per range. |
ToTimeline | Text Gantt chart from DateTime or TimeSpan intervals. |
ToColumns | Lays strings out in columns, like ls. |
ToBox | Draws a frame around text, with an optional title. |
ToCsv | RFC 4180 CSV with a header row and CSV-injection protection. |
The sequence methods live in System.Collections.Generic and ToBox lives in System, so they need no extra using.
Every multi-line result uses Environment.NewLine between lines and has no trailing line break (ToCsv is the
exception: it ends every row with CRLF). Widths are counted in UTF-16 characters, so East Asian wide characters and
emoji in cells or labels can push columns out of line.
Tables
The three table methods read the public, readable, non-indexer properties of T and make one column per property,
in declaration order. Property names become the headers.
ToTable
ToTable draws a box-drawn table for consoles and logs. Numeric columns (int, decimal, double, nullable
numbers, ...) are right-aligned; everything else is left-aligned. Headers are always left-aligned.
public sealed record Order(int Id, string Customer, int Items, decimal Total);
Order[] orders =
{
new Order(1001, "Contoso Ltd.", 3, 249.90m),
new Order(1002, "Fabrikam", 12, 1499.00m),
new Order(1003, "Northwind Traders", 1, 19.50m),
};
Console.WriteLine(orders.ToTable());
┌──────┬───────────────────┬───────┬─────────┐
│ Id │ Customer │ Items │ Total │
├──────┼───────────────────┼───────┼─────────┤
│ 1001 │ Contoso Ltd. │ 3 │ 249.90 │
│ 1002 │ Fabrikam │ 12 │ 1499.00 │
│ 1003 │ Northwind Traders │ 1 │ 19.50 │
└──────┴───────────────────┴───────┴─────────┘
A sequence of simple values (numbers, strings, enums, dates, ...) gets a single column named Value:
int[] retryCounts = { 1, 22, 333 };
string table = retryCounts.ToTable();
┌───────┐
│ Value │
├───────┤
│ 1 │
│ 22 │
│ 333 │
└───────┘
- A
nullproperty value becomes an empty cell. Line breaks inside a cell become spaces. - An empty sequence draws only the header row and the borders.
- A property whose type is another object shows that object's
ToString(). - A
nullsource throwsArgumentNullException.
Cells are formatted with the current culture, unlike the charts and ToCsv on this page. Under de-DE,
1499.5m shows as 1499,5 and a DateTime uses the German date format. Set CultureInfo.CurrentCulture if you
need a fixed format.
A null item in the sequence (as opposed to a null property value) is not skipped. For a type with properties,
reading them throws TargetException, so filter nulls out first. In the single Value column of simple values, a
null item shows as an empty cell.
ToMarkdownTable
ToMarkdownTable writes the same columns as a GitHub-style Markdown table. Numeric columns get the ---: alignment
marker so they render right-aligned. Paste the result into a README, a pull request or a chat message.
string markdown = orders.ToMarkdownTable();
| Id | Customer | Items | Total |
| ---: | --- | ---: | ---: |
| 1001 | Contoso Ltd. | 3 | 249.90 |
| 1002 | Fabrikam | 12 | 1499.00 |
| 1003 | Northwind Traders | 1 | 19.50 |
Pipe characters inside cells are escaped, so they do not split the cell:
public sealed record User(string Name, string? Email);
User[] users = { new User("Ann", null), new User("Bob | Admin", "bob@example.com") };
string markdown = users.ToMarkdownTable();
| Name | Email |
| --- | --- |
| Ann | |
| Bob \| Admin | bob@example.com |
The rules for null values, line breaks, empty sequences, culture and null items are the same as for
ToTable.
ToHtmlTable
ToHtmlTable renders a plain <table> with a <thead> and a <tbody>, on one line and with no attributes or CSS
classes. Header names and cell values are HTML-encoded, so user data cannot inject markup. Use it for e-mail reports
or a quick admin page.
User[] users =
{
new User("Ann", "ann@example.com"),
new User("<script>alert('x')</script>", null),
};
string html = users.ToHtmlTable();
<table><thead><tr><th>Name</th><th>Email</th></tr></thead><tbody><tr><td>Ann</td><td>ann@example.com</td></tr><tr><td><script>alert('x')</script></td><td></td></tr></tbody></table>
- A
nullvalue becomes an empty<td></td>. - An empty sequence gives the header and an empty body:
<table><thead><tr><th>Name</th><th>Email</th></tr></thead><tbody></tbody></table>. - Unlike
ToTableandToMarkdownTable, there is no singleValuecolumn for simple values: the columns are always the properties ofT, sonew[] { 1, 22 }.ToHtmlTable()gives<table><thead><tr></tr></thead><tbody><tr></tr><tr></tr></tbody></table>. - Values use the current culture, like
ToTable. Anullitem throwsTargetException. - A
nullsource throwsArgumentNullException.
Trees
ToTreeString
ToTreeString prints a hierarchy with branch lines, like the tree command. You pass two selectors: one returns a
node's children, the other returns the text for a node. It works on any node type: folders, menu items,
organization charts, dependency graphs.
public sealed record Folder(string Name, IEnumerable<Folder>? Children);
Folder src = new Folder("src", new[]
{
new Folder("Orders", new[] { new Folder("OrderService.cs", null), new Folder("Invoice.cs", null) }),
new Folder("Users", new[] { new Folder("UserRepository.cs", null) }),
new Folder("Program.cs", null),
});
string tree = src.ToTreeString(folder => folder.Children, folder => folder.Name);
src
├── Orders
│ ├── OrderService.cs
│ └── Invoice.cs
├── Users
│ └── UserRepository.cs
└── Program.cs
Call it on a sequence of roots to print a forest. Each tree follows the previous one, in order:
Folder[] services =
{
new Folder("api", new[] { new Folder("Controllers", null) }),
new Folder("worker", new[] { new Folder("Jobs", null) }),
};
string forest = services.ToTreeString(folder => folder.Children, folder => folder.Name);
api
└── Controllers
worker
└── Jobs
- A children selector that returns
nullmeans "no children" (the node is a leaf). - A
nulllabel prints as an empty label after the branch, for example├──. - An empty forest returns an empty string.
- A
nullselector throwsArgumentNullException(childrenSelectororlabelSelector); anullsequence of roots throwsArgumentNullException(roots).
The renderer uses an explicit stack, so a tree 500 levels deep renders without a stack overflow. A tree deeper than
1000 levels throws InvalidOperationException. In practice that means a cycle, such as a node that is its own child.
Charts
The charts are plain text for consoles, logs and CI output. Numbers in them are formatted with the invariant
culture, whatever the current culture is. Bar charts and histograms draw bars in eighths of a character cell
(▏▎▍▌▋▊▉█), so small differences stay visible in a narrow chart. Every chart that takes a width has an overload
without it that uses 40 characters.
ToSparkline
ToSparkline draws one block character per value (▁▂▃▄▅▆▇█), scaled between the smallest and the largest value.
Use it to show a trend in one line, for example next to a metric in a status report. It has overloads for
IEnumerable<double>, IEnumerable<int>, IEnumerable<long> and IEnumerable<decimal>.
int[] ordersPerDay = { 120, 95, 180, 240, 160, 90, 300 };
string trend = ordersPerDay.ToSparkline();
// → "▂▁▄▆▃▁█"
string simple = new[] { 1, 5, 3, 8, 2 }.ToSparkline();
// → "▁▅▃█▂"
string withGap = new[] { 1.0, double.NaN, 3.0 }.ToSparkline();
// → "▁ █"
string flat = new[] { 4, 4, 4 }.ToSparkline();
// → "▄▄▄"
string prices = new[] { 1m, 4.5m, 8m }.ToSparkline();
// → "▁▅█"
- When every value is equal, every value draws the middle block
▄. double.NaNdraws a space and is ignored when finding the smallest and largest value. If every value isNaN, the result is all spaces.- An empty sequence returns an empty string. A
nullsource throwsArgumentNullException.
ToBarChart
ToBarChart draws one line per item: the label, a bar scaled to the largest value, and the value. Labels are padded
to the same width. Use the selector overload for your own objects, or the overload on (string Label, double Value)
tuples when you already have pairs.
public sealed record Hit(string Method, int Count);
Hit[] hits = { new Hit("GET", 1240), new Hit("POST", 410), new Hit("DELETE", 37) };
string chart = hits.ToBarChart(hit => hit.Method, hit => hit.Count, 20);
GET ████████████████████ 1,240
POST ██████▋ 410
DELETE ▋ 37
(string Label, double Value)[] revenue =
{
("Q1", 12500.5),
("Q2", 18200),
("Q3", 9100.25),
("Q4", -300),
};
string chart = revenue.ToBarChart(20);
Q1 █████████████▊ 12,500.5
Q2 ████████████████████ 18,200
Q3 ██████████ 9,100.25
Q4 -300
The overloads are:
| Overload | Bar width |
|---|---|
ToBarChart(label, value) | 40 |
ToBarChart(label, value, width) | width |
ToBarChart() on IEnumerable<(string Label, double Value)> | 40 |
ToBarChart(width) on IEnumerable<(string Label, double Value)> | width |
- Values are shown with the invariant format
#,0.##: thousands separators and up to two decimals (1234567.891shows as1,234,567.89). - A negative value draws no bar but still shows its value. If the largest value is zero or less, no bar is drawn.
- Line breaks in a label become spaces.
- An empty sequence returns an empty string.
- A
nullsource,labelorvaluethrowsArgumentNullException. Awidthof zero or less throwsArgumentOutOfRangeException.
ToHistogram
ToHistogram splits the values into buckets equal-width ranges between the smallest and the largest value, counts
how many values fall into each range, and draws a bar per range scaled to the fullest one. Use it to see the shape
of a distribution, such as response times. It has overloads for IEnumerable<double>, IEnumerable<int> and
IEnumerable<long>, each with and without width.
int[] responseTimesMs = { 12, 15, 18, 22, 25, 31, 48, 52, 95, 180 };
string histogram = responseTimesMs.ToHistogram(4, 20);
12 – 54 ████████████████████ 8
54 – 96 ██▌ 1
96 – 138 0
138 – 180 ██▌ 1
- Each range includes its lower bound. A value on a boundary goes into the upper range, and the largest value goes into the last range.
- Bounds are formatted with the invariant format
0.##and right-aligned: a 0–1 range split in three shows0 – 0.33,0.33 – 0.67and0.67 – 1. - When all values are equal there is only one range, holding everything:
new[] { 5L, 5L, 5L }.ToHistogram(3, 4)returns"5 – 5 ████ 3". double.NaNvalues are skipped. An empty sequence returns an empty string.- A
nullsource throwsArgumentNullException. Abucketsorwidthof zero or less throwsArgumentOutOfRangeException.
For exact numbers rather than a picture, use the percentile and summary helpers on the Statistics page.
ToTimeline
ToTimeline draws a text Gantt chart: one line per item with its label, a track where the item's interval is filled
(█) and the rest dotted (·), and the start and end as offsets from the earliest start. The track spans from the
earliest start to the latest end. Use it to see where the time went in a build, a batch job or a request pipeline.
Pass DateTime selectors when you have timestamps:
public sealed record Job(string Name, DateTime StartedAt, DateTime FinishedAt);
DateTime started = new DateTime(2024, 5, 17, 8, 0, 0, DateTimeKind.Utc);
Job[] jobs =
{
new Job("restore", started, started.AddSeconds(1.2)),
new Job("build", started.AddSeconds(1.2), started.AddSeconds(3.9)),
new Job("test", started.AddSeconds(3.9), started.AddSeconds(6)),
};
string timeline = jobs.ToTimeline(job => job.Name, job => job.StartedAt, job => job.FinishedAt, 20);
restore ████················ 0ms → 1.2s
build ····█████████······· 1.2s → 3.9s
test ·············███████ 3.9s → 6.0s
Pass TimeSpan selectors when you have offsets from a common zero, such as Stopwatch.Elapsed readings:
public sealed record Step(string Name, TimeSpan Start, TimeSpan End);
Step[] steps =
{
new Step("extract", TimeSpan.Zero, TimeSpan.FromMilliseconds(800)),
new Step("transform", TimeSpan.FromMilliseconds(800), TimeSpan.FromMilliseconds(2500)),
new Step("load", TimeSpan.FromMilliseconds(2500), TimeSpan.FromMilliseconds(3100)),
new Step("notify", TimeSpan.FromMilliseconds(3100), TimeSpan.FromMilliseconds(3200)),
};
string timeline = steps.ToTimeline(step => step.Name, step => step.Start, step => step.End, 24);
extract ██████·················· 0ms → 800ms
transform ······█████████████····· 800ms → 2.5s
load ···················████· 2.5s → 3.1s
notify ·······················█ 3.1s → 3.2s
- Offsets are compact: under a second
0ms,250ms; under a minute1.2s; under an hour3m 05s; under a day1h 02m; then2d 03h. - Items keep their input order; they are not sorted by start time.
- Every interval fills at least one cell, even an instant where start equals end.
- Line breaks in a label become spaces. An empty sequence returns an empty string.
- An item that ends before it starts throws
ArgumentExceptionforsource, and the message names its index:The item at index 1 ends before it starts. - A
nullsource,label,startorendthrowsArgumentNullException. Awidthof zero or less throwsArgumentOutOfRangeException.
Layout
ToColumns
ToColumns lays strings out in columns like the ls command: filled top to bottom, then left to right, with as many
columns as fit in totalWidth. Columns are separated by two spaces and each column is as wide as its widest item.
Use it for a list of commands, files or tags in a help screen.
string[] commands =
{
"build", "clean", "deploy", "help", "init", "migrate", "restore", "status", "test", "version",
};
string layout = commands.ToColumns(40);
build deploy init restore test
clean help migrate status version
string layout = new[] { "alpha", "beta", "gamma", "delta", "epsilon" }.ToColumns(20);
alpha delta
beta epsilon
gamma
- Lines have no trailing spaces.
- If one string is wider than
totalWidth, every item goes on its own line. - A
nullitem becomes an empty string; line breaks inside an item become spaces. - An empty sequence returns an empty string.
- A
nullsource throwsArgumentNullException. AtotalWidthof zero or less throwsArgumentOutOfRangeException.
ToBox
ToBox draws a box-drawing frame around text, with one space of padding on each side. The text may span several
lines. Pass a title to embed it in the top border; a title wider than the text widens the box. Use it to make a
summary stand out in console output.
string box = "Hello\nWorld".ToBox();
┌───────┐
│ Hello │
│ World │
└───────┘
string summary = "Orders: 3\nTotal: 1,768.40\nStatus: ready";
string box = summary.ToBox("Invoice INV-2024-0042");
┌─ Invoice INV-2024-0042 ─┐
│ Orders: 3 │
│ Total: 1,768.40 │
│ Status: ready │
└─────────────────────────┘
string box = "ok".ToBox("Deployment");
┌─ Deployment ─┐
│ ok │
└──────────────┘
\r\n,\nand\rall count as line breaks.- An empty string draws a box with one empty row. An empty title draws a plain top border, the same as
ToBox(). - A
nulltext throwsArgumentNullException(text); anulltitle throwsArgumentNullException(title).
Exports
ToCsv
ToCsv writes the items as CSV following RFC 4180: a header row of property names, then one row per item. Fields
that contain the separator, a double quote or a line break are quoted, and quotes inside them are doubled. Numbers
use the invariant culture and dates use ISO 8601, so the file reads the same on every machine.
public static string ToCsv<T>(this IEnumerable<T> source, char separator = ',', bool protectFormulas = true)
public sealed record InvoiceLine(
string Number,
string Customer,
string Note,
decimal Amount,
DateTime IssuedAt,
bool Paid);
InvoiceLine[] lines =
{
new InvoiceLine("INV-001", "Smith, John", "He said \"ship it\"", 1234.5m,
new DateTime(2024, 5, 17, 10, 30, 0), true),
new InvoiceLine("INV-002", "=HYPERLINK(\"http://evil\")", "-5 discount", -3m,
new DateTime(2024, 1, 2, 3, 4, 5, DateTimeKind.Utc), false),
};
File.WriteAllText("invoices.csv", lines.ToCsv());
Number,Customer,Note,Amount,IssuedAt,Paid
INV-001,"Smith, John","He said ""ship it""",1234.5,2024-05-17T10:30:00.0000000,true
INV-002,"'=HYPERLINK(""http://evil"")",'-5 discount,-3,2024-01-02T03:04:05.0000000Z,false
Pass a different separator for spreadsheets set to a locale that expects semicolons. Quoting then follows the new separator: a comma no longer needs quotes, a semicolon does.
string csv = lines.ToCsv(';');
Number;Customer;Note;Amount;IssuedAt;Paid
INV-001;Smith, John;"He said ""ship it""";1234.5;2024-05-17T10:30:00.0000000;true
INV-002;"'=HYPERLINK(""http://evil"")";'-5 discount;-3;2024-01-02T03:04:05.0000000Z;false
How values are written:
| Value | Written as |
|---|---|
null | empty field |
bool | true / false |
DateTime, DateTimeOffset | ISO 8601 round-trip (2024-01-02T03:04:05.0000000Z; a DateTimeOffset keeps its offset: 2024-01-02T03:04:05.0000000+00:00) |
DateOnly / TimeOnly | 2024-05-17 / 14:30:45 |
numbers, enums, other IFormattable | ToString with the invariant culture (1234.5, Shipped) |
| anything else | its ToString() |
Every row, including the last, ends with CRLF (\r\n). An empty sequence gives only the header row followed by CRLF.
Spreadsheets run a cell that starts with =, +, - or @ as a formula. Attackers use this to run commands or
leak data when someone opens an export. With protectFormulas: true (the default), text that starts with =, +,
-, @, a tab or a carriage return gets a leading apostrophe ('=HYPERLINK(...)), so the spreadsheet shows it as
text. Only string values are changed: a negative number such as -3 is written unchanged.
Pass protectFormulas: false only when the file is read by a program, not opened in a spreadsheet:
string csv = lines.ToCsv(protectFormulas: false);
Number,Customer,Note,Amount,IssuedAt,Paid
INV-001,"Smith, John","He said ""ship it""",1234.5,2024-05-17T10:30:00.0000000,true
INV-002,"=HYPERLINK(""http://evil"")",-5 discount,-3,2024-01-02T03:04:05.0000000Z,false
ToCsvreads the public, readable, non-indexer properties ofT. UnlikeToTableandToMarkdownTable, it has no singleValuecolumn for simple values, so use it with a type that has properties:new[] { 1, 22 }.ToCsv()gives only empty lines.- A
nullsource throwsArgumentNullException. Anullitem in the sequence throwsTargetException.
ToTable, ToMarkdownTable, ToHtmlTable and ToCsv read properties by reflection over typeof(T). Their T
is annotated so the trimmer keeps public properties. See Trimming and AOT.
Related helpers
Two helpers in the same class are documented on other pages:
ClosestMatchfinds the most similar string for "did you mean ...?" messages. See Fuzzy and natural matching.GroupAdjacentgroups runs of consecutive items with the same key. See Transforming sequences.
See also
- Statistics: percentiles, averages and other numbers behind the charts.
- Durations: human-readable time spans for reports.
- JSON and safe logging: writing objects to logs without leaking secrets.
- Trimming and AOT: what the reflection-based tables need when trimmed.