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.
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 |
| Effect | The line is emitted once per element of that list |
The 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.
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 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 doesn't exist | the sequence is undeclared | TDC206 |
<data name="…"> inside an each line | a named <data> is a column for Parquet, and Parquet collects columns per record, not per iterated row | TDC209 |
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.