Relational tables with each
Use it when one record really stands for several rows — a customer with a handful of orders, an invoice with line items, a post with tags — and you want them as separate rows in a child table, each carrying a foreign key back to the parent, all from a single config.
The each attribute repeats a
<line> once per element of a list. One
record turns into N output rows — a normalized table ready for a database. Pointed at a
<pool> instead, it prints the pool record by record, so a lookup
table comes out of the same config as the rows that reference it.
Example outputs below are illustrative. The exact values a generator emits can shift between core versions and seeds; the counts and the structural rules (which rows appear, which stay empty, that keys are unique) are what the feature guarantees.
- Athe parent rows, each with its own key
- Bthe child rows — every one of them carries its parent key, so nothing is orphaned
At a glance
| Where | What |
|---|---|
| Applies on | <line> |
| Value | The name of a sequence whose generator has repeat, or of a <pool> |
| Effect | The line is emitted once per element of that list, or once per record of that pool |
A target sequence must carry repeat — that's
what makes it a list to iterate over. Point each at anything else and TDC tells you
why (see the last section below).
The problem: a list crammed into one record
A customer placed three orders. On the record they sit as a list:
1;VIP;8648,7170,7063
That's no good for a database. You need an orders table where each order is its
own row, linked back to the customer. One record has to produce three rows — not one
row with a list in it.
The tool
Give the orders their own repeat list, then put
each on the order line so it fires once per order:
<env count="4" seed="each-demo" inject="${{%}}">
<sequence name="Id"><gen type="increment" value="1"/></sequence>
<sequence name="Name"><gen type="template" value="person.male.firstName"/></sequence>
<sequence name="Tier"><gen type="text" value="VIP,standard" percent="50,50"/></sequence>
<sequence name="VipOrders" parent="Tier.VIP">
<gen type="number" value="1000..9999" repeat="2..3"/>
</sequence>
<sequence name="StdOrders" parent="Tier.standard">
<gen type="number" value="100..999" repeat="0..2"/>
</sequence>
</env>
<block>
<line><data>INSERT INTO customers VALUES (${{Id}}, '${{Name}}', '${{Tier}}');</data></line>
<line each="VipOrders"><data>INSERT INTO orders VALUES (${{_item_id}}, ${{Id}}, ${{VipOrders}});</data></line>
<line each="StdOrders"><data>INSERT INTO orders VALUES (${{_item_id}}, ${{Id}}, ${{StdOrders}});</data></line>
</block>
What you get
INSERT INTO customers VALUES (1, 'James', 'standard'); INSERT INTO orders VALUES (4, 1, 433); INSERT INTO orders VALUES (5, 1, 474); INSERT INTO customers VALUES (2, 'Michael', 'standard'); INSERT INTO customers VALUES (3, 'John', 'VIP'); INSERT INTO orders VALUES (11, 3, 2460); INSERT INTO orders VALUES (12, 3, 5137); INSERT INTO orders VALUES (13, 3, 7717); INSERT INTO customers VALUES (4, 'Robert', 'VIP'); INSERT INTO orders VALUES (16, 4, 5249); INSERT INTO orders VALUES (17, 4, 2324);
The order line is written once in the config, yet it prints as many times as the
customer has orders. Customer 2 (Michael) drew zero orders, so there are no
order rows for him at all and no empty placeholder left behind. The two VIP
customers draw from VipOrders and the standard customers from StdOrders, and each
row iterates the right list because
parent already settled which list is
active.
Why/when: this is the one-config way to emit a parent table and its child table together, correctly linked — no post-processing, no second run, no scripting to explode the list.
What's visible inside an each line
Inside a line that carries each, the iterated sequence name refers to the current
element, and two extra built-ins become available:
| Token | What it means |
|---|---|
${{VipOrders}} | the current element. Outside the each line, the same name is the whole joined list |
${{_item}} | position within the record: 1, 2, 3 |
${{_item_id}} | a global, unique number across the whole run — your primary key |
| everything else | as usual: ${{Id}}, ${{_count}}, any other sequence |
This is exactly why the foreign key works: ${{Id}} still means the customer on
every order row, not the element. If iterating rebound Id to the element, every order
would point at the wrong customer. The parent's columns stay fixed; only the iterated
name and _item / _item_id advance.
The walked name also carries the line's positions:
| Token | What it means |
|---|---|
${{VipOrders._item}} | the number of this line among the lines the each prints: 1, 2, 3 |
${{VipOrders._first}} | true on the first line printed, false on the rest |
${{VipOrders._last}} | true on the last line printed, false on the rest |
They are named after the walked list, so they never mix with the run's own
_first and _last, which mark the first and last
record. _last on a walked list is what puts a comma between elements and not after
the final one: <data if="!VipOrders._last">,</data>.
An if= on an each line, and on every <data>
inside it, is asked of each element, with the element in scope —
<line each="VipOrders" if="VipOrders > 5000"> prints only the large orders. The positions
count the lines that survive the filter, so VipOrders._last is the last line actually
printed.
About the order numbers
Look at the primary keys: 4, 5, then 11, 12, 13, then 16, 17. They rise, but
with gaps.
That's deliberate. _item_id is computed from the record's number, so a row can be
produced without knowing its neighbors — which is what keeps
--jobs parallelism byte-for-byte identical to a single-threaded
run. The price is a gap wherever a record has fewer orders than the maximum. For a primary
key that's perfectly fine — real databases rarely have gap-free IDs either.
Uniqueness is still ironclad, including across multiple lists — StdOrders uses
4, 5 while VipOrders uses 11, 12, 13, and the two ranges never collide. In a
larger check, 2000 records produced 3501 orders with 3501 distinct keys, and zero orders
pointing at a nonexistent customer.
Why/when: trust _item_id as a stable, unique, parallel-safe surrogate key.
Reach for _item instead when you want a per-record sequence number (1, 2, 3 that
restarts on every parent).
A whole pool, record by record
A <pool> is a small table built once, before the rows, and the
rows draw their members from it. each with the pool's name prints that table: one line per
record, in pool order, with every field read as ${{Pool.field}}. That is the lookup table
the rows' foreign keys point at:
<env count="4" seed="visits" local="en">
<pool name="Doctors" count="3">
<sequence name="id"><gen type="increment" value="1"/></sequence>
<sequence name="last"><gen type="template" value="person.lastName"/></sequence>
</pool>
<sequence name="Id"><gen type="increment" value="1"/></sequence>
<sequence name="Doc"><gen type="pool" value="Doctors"/></sequence>
<before>
<line each="Doctors"><data>INSERT INTO doctors VALUES (${{Doctors.id}}, '${{Doctors.last | sql}}');</data></line>
</before>
</env>
<block>
<line><data>INSERT INTO visits VALUES (${{Id}}, ${{Doc.id}});</data></line>
</block>
INSERT INTO doctors VALUES (1, 'Smith'); INSERT INTO doctors VALUES (2, 'Johnson'); INSERT INTO doctors VALUES (3, 'Williams'); INSERT INTO visits VALUES (1, 1); INSERT INTO visits VALUES (2, 3); INSERT INTO visits VALUES (3, 2); INSERT INTO visits VALUES (4, 2);
Every doctor a visit names exists in doctors, because both come from the same pool. The
pool is the same on every row, so a line walking it prints the whole table each time it
runs: put it in <before> or <after>, or in a
run with count="1", to print it once.
A field that draws from another pool is a record one level down, and reads the same way:
${{Doctors.dept.name}}. The positions work as on a list — Doctors._last is the last
record printed — and so does a filter:
<line><data>[</data></line>
<line each="Doctors" if="Doctors.shift == day"><data> ${{Doctors.last | json}}</data><data if="!Doctors._last">,</data></line>
<line><data>]</data></line>
[ "Johnson", "Smith" ]
The pool holds five doctors, and the last of them works nights, so the filter drops that
record. Doctors._last falls on Smith, the last record printed, and the array closes
without a trailing comma. Output formats → JSON builds
a whole document out of two pools this way.
Several lines per element: <group>
each on a <line> repeats one line. When an element needs several — a department, then
its doctors, then a closing bracket — wrap the lines in a <group>. <group each="…"> prints
everything inside it once per element, in order, with that element in scope for every line:
<block>
<line><data>[</data></line>
<group each="Depts">
<line><data> {"name": ${{Depts.name | json}}, "doctors": [</data></line>
<group each="Doctors" if="Doctors.dept.id == Depts.id">
<line><data> {"name": ${{Doctors.last | json}},</data></line>
<line><data> "languages": ${{Doctors.languages | json:;}}}</data><data if="!Doctors._last">,</data></line>
</group>
<line><data> ]}</data><data if="!Depts._last">,</data></line>
</group>
<line><data>]</data></line>
</block>
The Depts and Doctors pools are the ones from
Output formats → Two tables in one document.
[
{"name": "Cardiology", "doctors": [
{"name": "Williams",
"languages": ["English", "Polish"]}
]},
{"name": "Neurology", "doctors": [
{"name": "Smith",
"languages": ["English"]},
{"name": "Johnson",
"languages": ["English"]}
]}
]The outer group prints three lines for each department. Inside it, ${{Depts.name}} is the
department being printed, so the inner group, which walks all the doctors, keeps the ones whose
dept.id matches. Each level counts its own positions: Doctors._last is the last doctor printed
in this department and starts over in the next one, and Depts._last is the last department.
That is why every comma lands where JSON needs it. A group can hold lines and other groups, as
deep as the document goes.
Without each, a group's if= keeps or drops all of its lines at once — one condition instead of
the same one copied onto every line:
<block>
<line><data>#${{Id}} ${{Kind}}</data></line>
<group if="Kind == visit">
<line><data> room ${{Room}}</data></line>
<line><data> check-in at the front desk</data></line>
</group>
</block>
#1 call #2 visit room 122 check-in at the front desk #3 visit room 116 check-in at the front desk #4 call
A line inside a group keeps its own if= and may walk a list of its own with each. A group's
if= can read the element it walks but not its positions, which count the elements that if=
keeps (TDC215). A typed column — <data name="…"> — cannot sit anywhere inside a group
(TDC209): the group prints its lines several times or not at all, and a column has one cell per
record.
A lifecycle, not just a list
A record's rows do not have to be a bag of unrelated values. They can be a history:
an order that goes created → paid → shipped → delivered, one row per step, and never a
step out of sequence.
Two pieces do it. A <mix> picks which path this order takes, and
if on each <line> decides whether that step
belongs to the path — so the steps are written in the order they happen, and only the
lines of the chosen path fire:
<env count="20" seed="lifecycle" local="en">
<sequence name="OrderId"><gen type="increment" value="1000"/></sequence>
<mix name="Outcome" percent="60,25,15">
<case><gen type="text" value="delivered"/></case>
<case><gen type="text" value="refunded"/></case>
<case><gen type="text" value="cancelled"/></case>
</mix>
</env>
<block>
<line><data>${{OrderId}},1,created</data></line>
<line if="Outcome.delivered"><data>${{OrderId}},2,paid</data></line>
<line if="Outcome.delivered"><data>${{OrderId}},3,shipped</data></line>
<line if="Outcome.delivered"><data>${{OrderId}},4,delivered</data></line>
<line if="Outcome.refunded"><data>${{OrderId}},2,paid</data></line>
<line if="Outcome.refunded"><data>${{OrderId}},3,refunded</data></line>
<line if="Outcome.cancelled"><data>${{OrderId}},2,cancelled</data></line>
</block>
1000,1,created 1000,2,paid 1000,3,shipped 1000,4,delivered 1001,1,created 1001,2,paid 1001,3,refunded 1002,1,created 1002,2,paid 1002,3,shipped 1002,4,delivered 1003,1,created 1003,2,paid 1003,3,shipped 1003,4,delivered 1004,1,created 1004,2,paid 1004,3,refunded
Every order takes a legal path — a shipped order was paid first, a cancelled one
never shipped — and the outcomes land in the exact proportions <mix> declared. The step
number is written into the line rather than counted, because the lines of one path are
known when the config is written.
One sequence can hold the whole path — <gen type="text" value="created,paid,shipped,delivered" repeat="4" order="sequential"/> — with
each to unroll it. A fixed repeat on a walked list
takes N values off the walk per row, so a list of four read four at a time is the whole
path on every record:
<sequence name="Order"><gen type="increment" value="1000"/></sequence>
<sequence name="Step"><gen type="text" value="created,paid,shipped,delivered" repeat="4" order="sequential"/></sequence>
...
<line each="Step"><data>${{Order}},${{Step}}</data></line>
1000,created 1000,paid 1000,shipped 1000,delivered 1001,created 1001,paid
Use it when every record follows the SAME path. The <mix> above is for when they do
not: there, one order is cancelled and another delivered, in declared proportions, and no
walked list can say that. See order= / cycle= for the
walk's rules and the two shapes it refuses.
Why it is built this way. A status column that changed by "looking at the previous row" would force the run to be computed in order, from the first row. Choosing a whole path up front and unrolling it keeps every record independent — so this works on the streaming engines and in parallel, unchanged.
The same shape covers anything with a fixed vocabulary of steps: a support ticket
(open → assigned → resolved), a delivery, a moderation queue, an onboarding funnel.
Where it won't work
each is strict about what it can iterate, and fails loudly rather than guessing:
| What | Why | Error |
|---|---|---|
each on a sequence with no repeat | there's nothing to iterate over | TDC207 |
each on a name that is neither a sequence nor a pool | nothing by that name exists, so the line would print nothing at all | TDC206 |
<data name="…"> inside an each line or a <group> | a named <data> is a column for Parquet, and Parquet collects columns per record, not per iterated row | TDC209 |
Orders._last in the if= of the same each line or group | the positions count the lines that if= lets through, so they cannot decide which lines those are | TDC215 |
error[TDC207]: each="Tier" — that sequence holds one value, not a list note: Add repeat= to its <gen>, e.g. <gen … repeat="1..5"/>, or drop each=.
For Parquet output you don't need each at all: a
repeat list stays a real list inside the column,
which is already the right shape for a columnar file. each is the tool for text
formats — SQL, CSV, JSON lines — where one record must become several physical rows.
See also
- Hierarchical dependencies —
parent, which decides which list is active on each row. - Coherent & relational data — the other way to relate tables, by shared parent lookup.
repeat/separator— how a sequence becomes a list in the first place.- Builtins —
_item,_item_id,_countand friends. - Pools — the tables an
eachline can print, and how rows draw from them.