Skip to main content

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​

MethodWhat it does
ToTableBox-drawn text table, one column per property, numbers right-aligned.
ToMarkdownTableGitHub-style Markdown table, numeric columns right-aligned.
ToHtmlTableHTML-encoded <table> element, one column per property.
ToTreeStringPrints a tree or a forest with branch lines, like the tree command.
ToSparkline[1, 5, 3, 8, 2] → "▁▅▃█▂"
ToBarChartHorizontal bar chart with labels and values.
ToHistogramCounts values in equal-width ranges and draws a bar per range.
ToTimelineText Gantt chart from DateTime or TimeSpan intervals.
ToColumnsLays strings out in columns, like ls.
ToBoxDraws a frame around text, with an optional title.
ToCsvRFC 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.

Lines and widths

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.

Printing orders in a console
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());
Output
┌──────┬───────────────────┬───────┬─────────┐
│ 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:

A single Value column
int[] retryCounts = { 1, 22, 333 };

string table = retryCounts.ToTable();
Output
┌───────┐
│ Value │
├───────┤
│ 1 │
│ 22 │
│ 333 │
└───────┘
  • A null property 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 null source throws ArgumentNullException.
Culture and null items

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.

A Markdown table for a pull request comment
string markdown = orders.ToMarkdownTable();
Output
| 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:

Escaping pipes
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();
Output
| 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.

An HTML table for an e-mail report
User[] users =
{
new User("Ann", "ann@example.com"),
new User("<script>alert('x')</script>", null),
};

string html = users.ToHtmlTable();
Output
<table><thead><tr><th>Name</th><th>Email</th></tr></thead><tbody><tr><td>Ann</td><td>ann@example.com</td></tr><tr><td>&lt;script&gt;alert(&#39;x&#39;)&lt;/script&gt;</td><td></td></tr></tbody></table>
  • A null value 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 ToTable and ToMarkdownTable, there is no single Value column for simple values: the columns are always the properties of T, so new[] { 1, 22 }.ToHtmlTable() gives <table><thead><tr></tr></thead><tbody><tr></tr><tr></tr></tbody></table>.
  • Values use the current culture, like ToTable. A null item throws TargetException.
  • A null source throws ArgumentNullException.

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.

Printing a folder tree
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);
Output
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:

Printing several trees
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);
Output
api
└── Controllers
worker
└── Jobs
  • A children selector that returns null means "no children" (the node is a leaf).
  • A null label prints as an empty label after the branch, for example ├── .
  • An empty forest returns an empty string.
  • A null selector throws ArgumentNullException (childrenSelector or labelSelector); a null sequence of roots throws ArgumentNullException (roots).
Deep trees and cycles

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>.

Orders per day this week
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.NaN draws a space and is ignored when finding the smallest and largest value. If every value is NaN, the result is all spaces.
  • An empty sequence returns an empty string. A null source throws ArgumentNullException.

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.

Requests by HTTP method
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);
Output
GET ████████████████████ 1,240
POST ██████▋ 410
DELETE ▋ 37
Revenue per quarter from (label, value) pairs
(string Label, double Value)[] revenue =
{
("Q1", 12500.5),
("Q2", 18200),
("Q3", 9100.25),
("Q4", -300),
};

string chart = revenue.ToBarChart(20);
Output
Q1 █████████████▊ 12,500.5
Q2 ████████████████████ 18,200
Q3 ██████████ 9,100.25
Q4 -300

The overloads are:

OverloadBar 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.891 shows as 1,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 null source, label or value throws ArgumentNullException. A width of zero or less throws ArgumentOutOfRangeException.

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.

Distribution of API response times
int[] responseTimesMs = { 12, 15, 18, 22, 25, 31, 48, 52, 95, 180 };

string histogram = responseTimesMs.ToHistogram(4, 20);
Output
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 shows 0 – 0.33, 0.33 – 0.67 and 0.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.NaN values are skipped. An empty sequence returns an empty string.
  • A null source throws ArgumentNullException. A buckets or width of zero or less throws ArgumentOutOfRangeException.
tip

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:

Build steps with 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);
Output
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:

Import pipeline timed with a Stopwatch
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);
Output
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 minute 1.2s; under an hour 3m 05s; under a day 1h 02m; then 2d 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 ArgumentException for source, and the message names its index: The item at index 1 ends before it starts.
  • A null source, label, start or end throws ArgumentNullException. A width of zero or less throws ArgumentOutOfRangeException.

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.

A help screen listing commands
string[] commands =
{
"build", "clean", "deploy", "help", "init", "migrate", "restore", "status", "test", "version",
};

string layout = commands.ToColumns(40);
Output
build deploy init restore test
clean help migrate status version
Fewer columns when space is short
string layout = new[] { "alpha", "beta", "gamma", "delta", "epsilon" }.ToColumns(20);
Output
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 null item becomes an empty string; line breaks inside an item become spaces.
  • An empty sequence returns an empty string.
  • A null source throws ArgumentNullException. A totalWidth of zero or less throws ArgumentOutOfRangeException.

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.

Framing text
string box = "Hello\nWorld".ToBox();
Output
┌───────┐
│ Hello │
│ World │
└───────┘
A titled summary box
string summary = "Orders: 3\nTotal: 1,768.40\nStatus: ready";

string box = summary.ToBox("Invoice INV-2024-0042");
Output
┌─ Invoice INV-2024-0042 ─┐
│ Orders: 3 │
│ Total: 1,768.40 │
│ Status: ready │
└─────────────────────────┘
A title wider than the text
string box = "ok".ToBox("Deployment");
Output
┌─ Deployment ─┐
│ ok │
└──────────────┘
  • \r\n, \n and \r all 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 null text throws ArgumentNullException (text); a null title throws ArgumentNullException (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.

Signature
public static string ToCsv<T>(this IEnumerable<T> source, char separator = ',', bool protectFormulas = true)
Exporting invoice lines
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());
invoices.csv
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.

Semicolon-separated export
string csv = lines.ToCsv(';');
Output
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:

ValueWritten as
nullempty field
booltrue / false
DateTime, DateTimeOffsetISO 8601 round-trip (2024-01-02T03:04:05.0000000Z; a DateTimeOffset keeps its offset: 2024-01-02T03:04:05.0000000+00:00)
DateOnly / TimeOnly2024-05-17 / 14:30:45
numbers, enums, other IFormattableToString with the invariant culture (1234.5, Shipped)
anything elseits ToString()

Every row, including the last, ends with CRLF (\r\n). An empty sequence gives only the header row followed by CRLF.

CSV injection

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:

Turning protection off
string csv = lines.ToCsv(protectFormulas: false);
Output
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
  • ToCsv reads the public, readable, non-indexer properties of T. Unlike ToTable and ToMarkdownTable, it has no single Value column for simple values, so use it with a type that has properties: new[] { 1, 22 }.ToCsv() gives only empty lines.
  • A null source throws ArgumentNullException. A null item in the sequence throws TargetException.
Trimming and Native AOT

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.

Two helpers in the same class are documented on other pages:

See also​