Two writers, one table model: the column shape is settled before a single row exists.
finmodel is our open-source project, and its render module exposes exactly one table model, which every writer reads and nothing else. Markdown and CSV are not two formatters with their own opinions about amounts. They are two printers for the same frames.
The order is the whole design.
tabulate takes a statement plus either a results object or a mapping, and the columns get decided first.
Results becomes one frame per period, and each column is named by the period id.
A mapping gets inspected value by value. If every value is itself a mapping, its keys become the columns in the order given, where a period key turns into its id and anything else into a string. If no value is a mapping, the whole thing is one frame under a single column named Amount.
If only some values are mappings, we refuse it. Columns must be either all periods or all amounts. That mixture has no honest shape, so it raises rather than guessing. Anything that is neither results nor mapping gets refused by its type name.
Only then does the statement walk its nodes in declaration order. A block becomes a row whose amounts are None in every column. An input line carries its unit. Every other line is computed, with unit left None. Each cell refuses a frame that is missing that line id, and otherwise hands the value off to be converted.
Then comes the part that pays for all of it. The row turns None into empty text and a non-answer into its reason. So Markdown and CSV emit the same strings, and one pads and escapes them while the other prefixes id, label, depth and unit. There is no second definition of "empty" and no second explanation of why a cell has no number.
The business version of this: in a reporting product, every new export format is usually a new place for the numbers to disagree. Adding tests to each exporter treats the symptom. The structural fix, we think, is making exporters incapable of holding an opinion in the first place.
If your product ships more than one export, something in it already decides what a blank cell means, and that component is worth finding.