Output formats (CSV, JSON, SQL…)
TDC has no fixed list of exporters, and that's deliberate. The
output block assembles text —
<before> / <after>
wrap the whole run, <block> is one record, and
<data> is the raw text — so the same engine
produces CSV, JSON, SQL, YAML, NDJSON, or anything else built from characters.
The price of that freedom is that you own the format's syntax rules. TDC doesn't know
you're building JSON, so it won't add a comma, a quote, or a null for you — and it won't
add one where you don't want it either. This guide walks through the three most common
formats and the exact spots where each one breaks, then shows how to build any other shape.
Example outputs below are illustrative — exact values can differ from one core version or
seed to the next. What matters is the shape of each format, not the specific names or
numbers. Check a generated file with a real parser (python -m json.tool, sqlite3, a CSV
reader), never by eye — every break below looks fine at a glance.
CSV
CSV is the friendliest target: a header once, then one row per record. Two things bite — where the header goes, and any value that contains the delimiter or a quote.
The header — once, via <before>
A header belongs in <before>: it prints exactly
once, above every record, whether count is 3 or 3 million.
<tdc>
<env count="3" seed="demo">
<before><line><data>id,name,category</data></line></before>
<sequence name="Id"><gen type="increment" value="1"/></sequence>
<sequence name="Name"><gen type="text" value="Pen,Mug,Notebook"/></sequence>
<sequence name="Cat"><gen type="text" value="Office,Kitchen,Office"/></sequence>
</env>
<block>
<line><data>${{Id}},${{Name}},${{Cat}}</data></line>
</block>
</tdc>
id,name,category 1,Notebook,Office 2,Mug,Office 3,Pen,Kitchen
Putting the header in <block> would repeat it on every row; putting it in <before>
prints it once. Use <after> the same way for a trailing summary line or a closing marker.
csv — a value with a comma or a quote
Problem. A product name like Knife set, 3 pcs contains the field separator. Written
plainly, that comma becomes a column break: the row grows an extra field, categories
slide into prices, and — worst of all — the file still opens without an error. The damage
only shows up when a program reads it back.
<block>
<line><data>${{Id}},${{Name}},${{Cat}},${{Price}}</data></line>
</block>
# looks fine to the eye: 7,"Knife set, 3 pcs",Kitchen,32.00 # what a CSV reader actually sees — 5 fields, not 4: 7 | Knife set | 3 pcs | Kitchen | 32.00
Tool. The csv filter wraps a field in quotes and doubles any
inner quote — exactly what RFC 4180 asks for. It quotes every field you put it on, not
just the ones that would break without it; a quoted field is always valid CSV, and a
reader can't tell the difference. Put it on every text field that comes from outside
your config:
<block>
<line><data>${{Id}},${{Name | csv}},${{Cat}},${{Price}}</data></line>
</block>
id,name,category,price 7,"Knife set, 3 pcs",Kitchen,32.00 8,"Coffee ""Arabica"" 250g",Grocery,5.40 9,"Mug",Kitchen,4.10
The commas and quotes now live safely inside quoted fields; a reader gets four columns on every row. Use it when any value could carry a comma, a quote, or a newline — which is almost always true of names, titles, and street addresses pulled from a real data file.
A different delimiter
The separator is just a character you type. Want semicolons (common when the values already
contain commas) or tabs? Change the literal text in <data> and in the header to match —
there's no mode to switch.
<env count="3" seed="demo">
<before><line><data>id;name;category</data></line></before>
<!-- the Id / Name / Cat sequences, unchanged -->
</env>
<block><line><data>${{Id}};${{Name}};${{Cat}}</data></line></block>
id;name;category 1;Pen;Office 2;Mug;Kitchen
(The delimiter attribute is a separate thing — it tells the
file generator how to read an input CSV. On the output side
you write the character you want.)
JSON
JSON has no "mode" in TDC either — it's text, same as CSV. But it has four syntax rules that a plain text assembler will happily break. Here are all four, and the fix for each.
An array with no trailing comma
Problem. Three objects aren't JSON until something joins them into an array. The obvious move — a comma at the end of each object — puts a comma after the last one too, and every parser rejects it:
"age": 21 }, ] JSONDecodeError: Expecting value: line 17 column 1
Two clean fixes.
The first one uses a condition. A second <data if="!_last">
prints a comma on every record except the last — _last is the built-in "final record"
flag — so the JSON closes cleanly:
<tdc>
<env count="3" seed="demo">
<before><line><data>[</data></line></before>
<after><line><data>]</data></line></after>
<sequence name="Id"><gen type="increment" value="1"/></sequence>
<sequence name="Name"><gen type="template" value="person.male.firstName"/></sequence>
</env>
<block>
<line><data> {"id": ${{Id}}, "name": "${{Name}}"}</data><data if="!_last">,</data></line>
</block>
</tdc>
[
{"id": 1, "name": "Robert"},
{"id": 2, "name": "John"},
{"id": 3, "name": "James"}
]The second one uses a dedicated fixture. <delimiter_block>
prints its text between records — count - 1 times, never after the final one — which
is exactly what "a comma between array elements" means:
<env count="3" seed="demo">
<before><line><data>[</data></line></before>
<delimiter_block><line><data>,</data></line></delimiter_block>
<after><line><data>]</data></line></after>
...
</env>
Use if="!_last" when the comma rides on the same line as the object; reach for
<delimiter_block> when a record spans several lines and the separator needs a line of its
own. A comma alone on a line looks odd, but it's valid JSON — newlines between array
elements mean nothing. Run the file through jq . if you want it pretty-printed.
Quotes are yours to place
Notice the quotes above: "${{Name}}" has them, ${{Id}} doesn't. TDC knows nothing about
JSON types — you place the quotes, and that's what keeps a number a number and a string a
string.
<line><data> "id": ${{Id}}, "zip": "${{Zip}}", "age": ${{Age}}</data></line>
"id": 1, "zip": "07105", "age": 34
zip is quoted on purpose: a postal code isn't a number (you never do arithmetic on it,
and a leading zero would vanish). id and age stay bare so they parse as integers.
Nesting is just more lines
A nested object needs no special syntax — it's a few more <data> lines with the right
braces and indentation:
<line><data> "address": {</data></line>
<line><data> "city": "${{City}}",</data></line>
<line><data> "zip": "${{Zip}}"</data></line>
<line><data> },</data></line>
"address": {
"city": "Chicago",
"zip": "60614"
},An array inside an object
Several values in one field come from repeat, and
separator is what goes between them. JSON wants ", " —
with the quotes — and here you hit a wall: the grammar won't let you put a " inside an
attribute value, and " passes straight through as literal text.
The way around it is to separate the values with a neutral character they never contain,
then turn that into JSON with the replace filter, where quotes
are allowed:
<sequence name="Tags">
<gen type="text" value="sale,new,gift,vip" repeat="1..3" separator="~"/>
</sequence>
<line><data> "tags": ["${{Tags | replace:~,", "}}"],</data></line>
"tags": ["vip"], "tags": ["sale", "gift"], "tags": ["gift", "vip", "new"]
For an array of numbers, none of this applies — there are no quotes, so separator=","
is enough on its own. repeat draws each value
independently, so a tag can show up twice in one row; if you don't want that, pin a fixed
set with <mix> instead.
null is a word, not emptiness
Problem. For an optional field, missing is the obvious
thing to reach for — but missing produces an empty value, and JSON has no such thing
as empty:
<gen type="number" value="1..100" missing="0.5"/>
{"id": 1, "score": }
JSONDecodeError: Expecting value: line 1 column 20(In CSV that same emptiness is correct — an empty cell means "no value". Formats differ.)
Tool. Write the literal word null as one branch of a <mix>,
which also makes the share exact — on 50,000 rows you get precisely 10,000 nulls, not
"about 20%":
<mix name="Score" percent="80,20">
<case><gen type="number" value="1..100"/></case>
<case><data>null</data></case>
</mix>
{"id": 1, "score": 56}
{"id": 2, "score": null}
{"id": 3, "score": 12}A quote from outside data
The last break comes from values you don't control — a name pulled from a
file. A product called Coffee "Arabica" 250g lands in the string
as-is, and its first inner quote closes the string early:
{"name": "Coffee "Arabica" 250g"}
JSONDecodeError: Expecting ',' delimiterThe JSON standard escapes an inner quote with a backslash. The same
replace filter does it — this is the one place you write the
backslash yourself:
<data>{"name": "${{Name | replace:",\"}}"}</data>
{"name": "Coffee \"Arabica\" 250g"}That parses back to the original string, quotes intact.
If your data might also contain a literal backslash, double the backslash first (a
replace pass for \ → \\), then escape the quotes — otherwise the second pass mangles
the escapes the first one just added.
Many records: NDJSON
An array has a ceiling: to read a single object, a parser has to load the whole file, so
a million records means gigabytes in memory. The industry answer is NDJSON — one object
per line, no wrapper and no commas. pandas, ClickHouse, BigQuery, and jq all read it.
Just drop the three fixtures and fold each record onto one line:
<block><line>
<data>{"id": ${{Id}}, "name": "${{Name}}", "city": "${{City}}", "score": ${{Score}}}</data>
</line></block>
{"id": 1, "name": "James", "city": "Chicago", "score": 3}
{"id": 2, "name": "William", "city": "Austin", "score": null}
{"id": 3, "name": "Andrew", "city": "Chicago", "score": 12}A consumer reads it one line at a time and needs memory for a single record instead of the whole file — which makes it the format of choice past a few hundred thousand rows. See Large outputs & streaming for how TDC writes these to disk without holding the file in memory.
SQL
For SQL you emit statements a database runs directly. TDC doesn't connect to anything — it
writes a file of commands that you load with sqlite3, psql, or a migration. Two
format concerns here: escaping apostrophes, and wrapping the run in a schema and a
transaction.
sql — an apostrophe in a value
Problem. In SQL a string literal is delimited by single quotes, so a last name like
O'Brien closes the string early and the statement won't run:
INSERT INTO users (id, last) VALUES (2, 'O'Brien'); Error: near "Brien": syntax error
Tool. The sql filter doubles the apostrophe, which is how SQL
escapes it inside a literal:
<tdc>
<env count="3" seed="demo">
<sequence name="Id"><gen type="increment" value="1"/></sequence>
<sequence name="Last"><gen type="text" value="Chisholm,O'Brien,Foster" order="sequential"/></sequence>
</env>
<block>
<line><data>INSERT INTO users (id, last) VALUES (${{Id}}, '${{Last | sql}}');</data></line>
</block>
</tdc>
INSERT INTO users (id, last) VALUES (1, 'Chisholm'); INSERT INTO users (id, last) VALUES (2, 'O''Brien'); INSERT INTO users (id, last) VALUES (3, 'Foster');
'O''Brien' loads back as O'Brien. The stock data packs happen to carry no apostrophes,
which is why this example supplies its own list — but the moment you plug in your names,
apostrophes turn up, Irish and Italian ones especially. Put sql on every text field headed
into a statement: it costs nothing and saves you a failed import later.
Schema first, data after — <before> / <after>
The CREATE TABLE runs once, so it belongs in <before>,
which prints once, ahead of every row:
<env count="3" seed="shop">
<before>
<line><data>CREATE TABLE customers (</data></line>
<line><data> id INTEGER PRIMARY KEY,</data></line>
<line><data> name TEXT NOT NULL,</data></line>
<line><data> city TEXT NOT NULL</data></line>
<line><data>);</data></line>
</before>
<sequence name="Id"><gen type="increment" value="1"/></sequence>
<sequence name="First"><gen type="template" value="person.male.firstName"/></sequence>
<sequence name="Last"><gen type="template" value="person.lastName"/></sequence>
<sequence name="City"><gen type="text" value="Chicago,Austin,Denver" percent="50,30,20"/></sequence>
</env>
<block><line>
<data>INSERT INTO customers VALUES (${{Id}}, '${{First}} ${{Last | sql}}', '${{City}}');</data>
</line></block>
CREATE TABLE customers ( id INTEGER PRIMARY KEY, name TEXT NOT NULL, city TEXT NOT NULL ); INSERT INTO customers VALUES (1, 'James Smith', 'Denver'); INSERT INTO customers VALUES (2, 'Robert Williams', 'Austin'); INSERT INTO customers VALUES (3, 'John Johnson', 'Chicago');
Wrap it in one transaction
A thousand bare INSERTs are a thousand transactions — slow. Wrap the run in
BEGIN / COMMIT so it loads as one, which takes a single extra line in each fixture:
<env count="3" seed="demo">
<before>… schema …<line><data>BEGIN;</data></line></before>
<after><line><data>COMMIT;</data></line></after>
<!-- the sequences, unchanged -->
</env>
Then load it:
# SQLite sqlite3 shop.db < shop.sql # PostgreSQL — same file psql -f shop.sql
For related tables — orders that reference existing customers, with the foreign key always
valid — generate one line per list element with each. See
Coherent & relational data for the full pattern.
Anything else
The same three pieces build any text shape you like — you control every character:
- YAML — a
-before each list item, two-space indentation for nesting. - A Markdown table — a
|-delimited header plus a|---|rule in<before>, and|rows in<block>. - A fixed-width report — pad the fields to a set width with the
maskand slice filters, then line the columns up.
<env count="3" seed="demo">
<before>
<line><data>| id | name | city |</data></line>
<line><data>|----|-------|---------|</data></line>
</before>
<!-- the sequences, unchanged -->
</env>
<block><line><data>| ${{Id}} | ${{Name}} | ${{City}} |</data></line></block>
| id | name | city | |----|-------|---------| | 1 | James | Chicago | | 2 | Mary | Austin |
TDC just fills in the values — the format is whatever you wrote.
See also
- Output & formatting —
<block>,<line>,<data>, the fixtures, and theifcondition. - Masks & case — the
csv/sql/replaceescaping filters in full. - Reading files & CSV — pull your own values from a file with the
filegenerator. - Coherent & relational data — related tables, foreign keys, and
the
each/weight/rowattributes. - Large outputs & streaming — millions of rows and NDJSON on disk.