🍱 Lunchbox Hands

csv

Why Your CSV Breaks When You Convert It to a Markdown Table

Pipes that split cells, newlines that shear rows apart, quotes a naive converter mangles, and semicolon "CSV" from Excel — the exact bytes that break CSV-to-Markdown conversion, per RFC 4180 and the GFM spec.

You paste a CSV into a converter, get a Markdown table back, drop it into a README — and a cell is missing, a column has slid one position left, or GitHub renders the whole thing as a paragraph of pipes. None of that is random. CSV is a real data format with an escaping mechanism; a Markdown table is a line-oriented picture of data with almost none — and every conversion bug is a byte that CSV can carry but the table syntax can’t. Know which bytes those are and the failures become predictable, and avoidable.

Markdown tables aren’t Markdown

Start with the part most converter pages skip: tables are not in core Markdown. CommonMark, the spec that standardizes Markdown itself, has no table syntax at all. Tables come from the GitHub Flavored Markdown spec, section 4.10 “Tables (extension)”, which says it plainly: “GFM enables the table extension, where an additional leaf block type is available.”

That’s why a table that renders beautifully on GitHub can appear as literal pipe characters somewhere else — a strict CommonMark processor doesn’t have to support it. The syntax itself is two mandatory rows plus data:

| Name  | Role     |
| ----- | -------- |
| Ada   | Engineer |

The second line is the delimiter row — cells of hyphens, with optional colons for alignment: :--- left, :---: center, ---: right. It’s not decoration. It’s what makes the parser treat the block as a table at all.

The delimiter row is a contract, and it’s enforced

The GFM spec has a hard rule: the header row must match the delimiter row in the number of cells. If not, a table will not be recognized. Not “renders oddly” — not recognized. Feed a parser this:

| a | b |
| --- |
| 1 | 2 |

and you get a paragraph containing the literal text | a | b | | --- | | 1 | 2 |. (Verified against GitHub’s own micromark GFM-table parser — the whole block falls out of table parsing entirely.)

Body rows are treated more leniently, and the leniency is its own trap. Per the spec — and confirmed empirically — a body row with fewer cells than the header gets empty cells inserted, and a body row with more cells has the excess silently ignored. That last clause is how data disappears without an error:

| a | b |
| --- | --- |
| 1 | 2 | 3 |

renders as a two-column table containing 1 and 2. The 3 is gone. No warning, no overflow cell — dropped. Any conversion bug that adds a stray column doesn’t produce a visibly broken table; it produces a clean-looking table that quietly discarded your last column.

The pipe: one character that redraws your columns

The pipe is the table’s structural character, so a pipe inside your data splits the cell. A CSV of shell commands is the classic case:

command,description
grep foo file.txt | wc -l,count matching lines

Convert that naively and the output row is:

| grep foo file.txt | wc -l | count matching lines |

Three cells where you meant two — and since the excess-cell rule above drops everything past the header width, the rendered table shows grep foo file.txt in column one, wc -l in column two, and “count matching lines” is deleted. Verified: that exact input renders with the description cell gone.

The fix is in the GFM spec: escape the pipe as \|, which works even inside code spans within a cell.

| grep foo file.txt \| wc -l | count matching lines |

renders as two cells, pipe intact. This is the single most common way real-world CSV breaks a generated table — shell snippets, regex patterns, and TypeScript union types are full of a character that’s ordinary in CSV and load-bearing in the output.

Newlines: CSV can hold them, a table cell can’t

RFC 4180, the CSV spec, is explicit (rule 6): “Fields containing line breaks (CRLF), double quotes, and commas should be enclosed in double-quotes.” A quoted CSV field can legally span multiple lines:

name,notes
"Widget","First line
second line"

That’s one record, two fields. Markdown tables have no equivalent whatsoever. A table row is one physical line, full stop — the GFM spec breaks the table “at the first empty line, or beginning of another block-level structure,” and there is no quoting construct that lets a cell continue onto the next line. Convert that CSV by just dropping cell text into place and the embedded newline shears the row in two. Tested:

| a | b |
| --- | --- |
| line one
line two | x |

renders as a table where row one is line one plus an empty cell, and row two starts with line two — your data hasn’t just wrapped, it has moved to different cells. This is the same genre of silent reshuffling as Excel mangling dates and IDs on export: the format on the receiving end can’t represent what the source held, so the value gets reshaped instead of rejected.

The standard workaround: convert embedded newlines to <br>. GFM allows inline HTML, and GitHub renders <br> inside a cell as a line break — | First line<br>second line | is one cell, displayed on two lines. It’s the only way a table cell can show multi-line content.

Commas and quotes: where naive converters die

RFC 4180’s other escaping rule (rule 7): a double quote inside a quoted field “must be escaped by preceding it with another double quote.” So this is one row of three fields:

"Widget, large","5"" bolt, steel",12

Fields: Widget, large · 5" bolt, steel · 12. A converter that splits on commas — and plenty of quick scripts and bare-bones converter pages do exactly that — produces this instead (actual output of line.split(',') in Node):

["Widget  |  large"  |  "5" bolt  |  steel"  |  12]

Five “cells” from three fields, quote characters left embedded in the data, everything after each quoted comma shifted one column left — the same off-by-one corruption as the unescaped pipe, from the input side. Correct CSV parsing is a small state machine that tracks whether it’s inside quotes; any converter that doesn’t run one corrupts exactly the rows that needed quoting.

The “CSV” that isn’t comma-separated

One more input-side trap: the file may not use commas at all. When Excel saves a CSV, Microsoft’s documentation says “the default list separator (delimiter) is a comma. You can change this to another separator character using Windows Region settings” — the delimiter comes from the OS, not from the file format’s name. In most European locales the comma is the decimal separator (3,14), so the list separator defaults to a semicolon, and Excel happily writes name;price;qty into a file called .csv. Feed that to a comma-assuming converter and you get a one-column table with semicolons decorating every cell. A converter needs to detect the delimiter — comma, semicolon, or tab — or let you override it.

Whitespace and the alignment illusion

Two smaller ones. RFC 4180 rule 4 says spaces “are considered part of a field and should not be ignored” — but Markdown table parsing strips leading and trailing whitespace inside cells, so significant outer whitespace doesn’t survive the trip no matter what the converter does.

And the alignment colons do less than people expect: :---: centers the column in the rendered HTML, with no effect on how the raw text lines up in your editor. If you want the pipes aligned in the source — so the table is reviewable in a diff — the converter has to pad every cell with spaces. The padding is cosmetics for humans; the colons are what the renderer reads.

The failure modes at a glance

Byte in your CSVWhat a naive conversion producesThe correct handling
| in a cellCell splits; trailing cells silently droppedBackslash-escape the pipe
Newline in a quoted fieldRow shears; data lands in wrong cellsConvert to <br>
Comma in a quoted fieldExtra column; everything shifts leftReal CSV parser, not split(',')
"" (escaped quote)Stray quote characters in outputRFC 4180 rule 7: unescape to "
Semicolon delimiter (Excel, EU locale)One giant columnDelimiter detection or manual override
Ragged row (too few cells)GFM inserts empty cells — or drops the extrasPad, and tell you it happened

Converting without the traps

Our CSV to Markdown table converter was built around exactly this list, so it’s fair to say precisely what it does. It parses with a real CSV parser (Papa Parse), not a comma split, so quoted fields with embedded commas and doubled quotes arrive as single intact cells. The delimiter is auto-detected — comma, semicolon, or tab — with a manual override. Pipes in cell data are escaped to \|, and newlines inside quoted fields become <br>, per the two workarounds above. Ragged rows aren’t an error: they’re padded to the widest row and reported as a visible warning, so nothing is silently dropped the way GFM’s excess-cell rule would. You get per-column alignment, optional pretty-printing that pads the pipes into alignment, and a rendered preview. One honest caveat: cell whitespace is trimmed at the edges — which matches what Markdown rendering would do anyway, but means rule-4-significant spaces don’t round-trip. Everything runs in your browser; the CSV never uploads.

If you’re composing a table from scratch rather than converting one, the Markdown table generator builds the syntax cell by cell. And if the CSV itself is the thing you’re wrangling — inspecting what’s actually in those quoted fields before it goes anywhere — CSV ⇄ JSON uses the same parser, so the two tools agree about where your cells begin and end.

The short version: a Markdown table is a drawing of your data, and drawings have no escape hatch. Escape the pipes, <br> the newlines, parse the quotes properly, and check the delimiter — those four cover nearly every “converter broke my CSV” you’ll ever hit.

Two sibling posts cover the neighboring failure modes: NDJSON is not a JSON array with extra newlines does this treatment for line-delimited JSON, and if your CSV started life as nested JSON, flattening it has its own round-trip gotchas before the table format ever gets involved.