md2gd Now Syncs Markdown Tables to Google Docs
TL;DR
Markdown tables now sync, both ways with Google Docs, plain and column-aligned (left, right, center). md2gd does not turn them into a Google Docs table object. It renders the table as an aligned monospace pipe-grid: the Markdown table itself, in Roboto Mono, so the columns line up. A real Docs table is a nested tree of rows, cells, and paragraphs that does not fit a two-way sync built on flat blocks and hashing, and the Docs API makes editing one in place fragile. The pipe-grid fits the model and round-trips byte-for-byte. This post covers what shipped, why the obvious mapping fails, and what the pipe-grid costs you.
Tables now sync. That was the last Markdown construct md2gd would freeze on, and as of this release a table moves both ways between your file and the Google Doc, plain or column-aligned. What it does not do is become a Docs table. That choice says more about how the sync works than the feature itself does, so it is where this post spends its time.
A table looks like the easiest thing in the world to sync. You have | Name | Age | on the Markdown side and a table in the Google Doc, and the obvious move is to map one to the other. It turned out to be the construct that pushed hardest on the sync model, which is why it came last.
The obvious idea
Markdown has a table. Google Docs has a table. Map one to the other on each sync: read the GFM pipe rows, call the Docs API to insert a table with those rows and cells, and on the way back read the Docs table and print pipe rows. Every other tool that touches both formats does some version of this.
It works once. It does not survive a sync that runs continuously in both directions.
What a two-way sync needs
md2gd keeps one Markdown file and one Google Doc in step by reducing both sides to a canonical form, splitting each into blocks, and hashing every block. To decide what changed and merge it, it compares three sets of hashes: the file now, the Doc now, and the last synced state. A block whose hash matches the base did not change; a block whose hash differs did. That comparison is the whole engine.
Two properties fall out of that design, and both are strict:
- Every supported construct has to round-trip losslessly. Markdown to Doc and back must produce the exact same bytes, every time. If the bytes drift by even a space, the hash changes, and the engine sees a change on a block nobody touched.
- The document is a flat list of blocks. A paragraph, a heading, a code block, a quote. Each one is a single unit that hashes cleanly and merges on its own.
A construct earns a place in the supported set only when it satisfies both. That is why md2gd refuses things like footnotes or deeply nested lists instead of guessing: a lossy round-trip does not just look wrong once, it makes the same untouched block conflict on every sync, and fills your history with phantom changes. Refusing one doc is annoying. Corrupting it quietly, or flooding it with conflicts, is worse.
A table has to clear the same bar. The question is whether a real Docs table can.
Why a real Docs table does not fit
Three problems, and any one of them is enough.
A table is a tree, and the model is flat. A Docs table is not a block. It is a nested structure: a table contains rows, each row contains cells, each cell contains its own paragraphs. That tree does not reduce to a single block that hashes cleanly, and it does not merge like a paragraph does. Bolting a tree onto a flat block list means special-casing the merge for one construct, which is exactly the kind of exception that turns a sync engine into a bug farm.
Editing one in place is fragile. The Docs API edits a document by index: you send requests that insert or delete text at numeric positions. md2gd already had to get this ordering exactly right for ordinary text. Applying a named paragraph style after a text style silently wiped bold and italic. Creating list bullets reset formatting and stripped leading tabs. Those were bugs in request order alone, on flat paragraphs. A table multiplies the problem: every cell has its own index range, and those ranges shift as you edit the cells around them. Keeping a nested grid of indices correct across an in-place edit, on every sync, is a large surface for silent corruption.
The round-trip is not symmetric. What you can write into a Docs table through the API and what you read back are not the same shape. Cell content comes back wrapped in structure that has no clean Markdown equivalent, so reproducing the original pipe rows byte-for-byte means fighting the format on every pull. That is the drift the hashing design cannot tolerate.
So a real table fails all three tests: it does not fit the block model, it is fragile to edit, and it does not round-trip cleanly. Mapping to it would trade a working sync for a table that looks right until the second edit.
The pipe-grid
The construct that does clear the bar is one md2gd already handles perfectly: a code block. So a table is stored in the Doc as its own canonical Markdown, rendered in a monospace font so the columns line up on screen.
Write this:
| Feature | Status | Priority |
| :------ | -----: | :------: |
| Sync | On | high |
| Tables | On | high |
In the Google Doc it appears as that exact text in Roboto Mono, columns aligned, reading as a clean grid. It is a code-style block, which means it fits the flat block model, hashes like any block, merges like any block, and round-trips byte-for-byte. That last part is verified against a real Google Doc, not asserted: a gated integration test pushes each table to a live Doc and reads it back, and the bytes have to match.
Column alignment rides through the same way. The delimiter row carries the colons that GFM uses for alignment (:--- left, ---: right, :---: center), md2gd stores the alignment per column, and it renders the row back identically on every pull. Left, right, and center all round-trip. A plain table with no colons comes back byte-identical to how it went in, so a doc that was already syncing does not suddenly re-sync because the delimiter row shifted.
The pipe-grid is the canonical Markdown table itself. There is nothing to translate on the way back, which is precisely why it round-trips.
The constraints
The pipe-grid is a deliberate trade, not a free win. What it costs:
- Cells are single-line plain text. Bold or a link inside a cell renders as its literal Markdown (
**bold**) in the monospace grid. That is ugly, but it round-trips, so it never mangles your content. - A real inserted Docs table still freezes the doc. If you use Insert then Table in Google Docs, md2gd raises
TABLEand stops, because that is the nested object it cannot round-trip. The pipe-grid is how tables move from the Markdown side; a hand-inserted Docs table is the thing being refused. - One known ambiguity. A fenced code block whose contents happen to be a valid pipe table will read back as a table. It is the single case where the two representations collide, and it is rare enough to name rather than fix.
There is also a small fix that came with this work. A line with pipe characters that has no delimiter row under it, like see the pricing table | below, is not a GFM table and never was. md2gd now treats it as ordinary paragraph text with literal pipes, the way any Markdown renderer does, instead of mistaking it for a broken table and freezing the doc. Prose with a stray pipe in it syncs fine.
What this means when you write
Write normal Markdown tables, plain or aligned, and they sync. Keep cell contents to single-line text and you get a clean grid on both sides. If you build a table with Insert then Table inside the Doc, expect that one doc to freeze with a TABLE status until you remove it; nothing is broken and nothing was changed, md2gd just will not guess at a construct it cannot move safely. The full list of what syncs and what freezes, with the reasoning behind each refusal, is in what md2gd syncs and what it doesn't.
The larger point is the one the table made concrete: md2gd would rather show a table as a monospace grid that always round-trips than as a real Docs table that corrupts the moment someone edits it. A sync you can trust beats a table that photographs well.
Related reading
- What md2gd syncs, and what it refuses to
- Keep Markdown and Google Docs in sync
- Google Docs Markdown: what works and what breaks
- Building a table before you sync? Use the Markdown table generator.