Objects#
Objects are reusable arg templates defined in the objects section of your config. Each object is a map of field names to expressions, evaluated in declaration order. They remove duplication when the same set of generated values is needed across multiple queries.
object customer {
email = gen('email')
name = gen('name')
created_at = timestamp('2020-01-01T00:00:00Z', '2024-01-01T00:00:00Z')
}Referencing objects#
There are four ways to use an object in a query:
object: without args#
Set object: on the query to expand all fields as positional args in declaration order:
seed {
seed_customers(count: 1000, size: 100, object: customer)
`INSERT INTO customer (email, name, created_at)
__values__`
}Fields bind to $1, $2, $3 in declaration order and argument values (e.g. ('a@eg.com', 'a', '2026-05-18T...'), ('b@eg.com', 'b', '2026-04-21T...')) are expanded for you via the __values__ placeholder; regardless of your databases drive. This is the most concise form for batch inserts.
field()#
Cherry-picks fields from the object: context. Requires object: to be set. Mixable with other expressions:
seed {
seed_customers(count: 1000, size: 100, object: customer)
`INSERT INTO customer (email, name, city)
__values__` (
field('email'),
field('name'),
ref('fetch_cities').name
)
}obj('name', 'field')#
References a specific field from any object by name. Does not require object::
run {
insert_customer `INSERT INTO customer (email, name, created_at)
VALUES ($1, $2, $3)` (
obj('customer', 'email'),
obj('customer', 'name'),
obj('customer', 'created_at')
)
}obj('name').field#
Evaluates all fields and returns a map for dot-access. Does not require object::
run {
insert_customer `INSERT INTO customer (email, name, created_at)
VALUES ($1, $2, $3)` (
obj('customer').email,
obj('customer').name,
obj('customer').created_at
)
}The choice of approach is up to you but as a general rule, consider the following:
| Syntax | Requires object: | Evaluates | Best for |
|---|---|---|---|
field('email') | yes | single field from object context | Cherry-picking fields or mixing with other expressions |
obj('customer', 'email') | no | single field, explicit object | Run queries needing one field without binding object to query |
obj('customer').email | no | all fields, dot-access | Readable dot-notation access to multiple fields |
object: customer (no args) | yes | all fields, declaration order | Batch inserts where query columns match object fields 1:1 |
Multiple calls to obj() or field() within the same arg-set evaluation return the same instance, so generated values are consistent across args.
__columns__ and __values__#
When a query uses object: without explicit args, the __columns__ and __values__ placeholders are expanded automatically. __columns__ becomes a comma-separated list of field names; __values__ becomes the parameterised value rows.
seed {
seed_customers(count: 1000, size: 100, object: customer)
`INSERT INTO customer __columns__
__values__`
}Named args with __columns__#
When a query provides explicit named args, __columns__ expands from the arg names instead of the object fields. This is useful when child rows include columns not in the object (e.g. foreign keys from __parent__):
object account {
balance = initial_balance
}
object customer {
id = uuid_v4()
email = gen('email')
sub {
accounts = zipf.obj_n('account', 2.0, 1.0, 1, 5)
}
}
seed {
populate_customer(count: customers, size: batch_size, object: customer) `
INSERT INTO customer __columns__
__values__` (
field('id'),
field('email')
)
populate_account(count: count(populate_customer__accounts), size: batch_size) `
INSERT INTO account __columns__
__values__` (
balance: ref_each(populate_customer__accounts).balance,
customer_id: ref_each(populate_customer__accounts).__parent__.id
)
}Here __columns__ on populate_account expands to (balance, customer_id) - matching the named args, not the account object’s single field.
The expansion of __values__ is driver-specific:
pgx, mysql, mssql, spanner#
Row-oriented VALUES tuples:
INSERT INTO customer (email, name, created_at)
VALUES ('user1@example.com', 'Alice', '2023-06-15T10:30:00Z'),
('user2@example.com', 'Bob', '2021-03-22T14:15:00Z'),
...Oracle#
Oracle does not support multi-row VALUES. Use __values__(table(cols)) to generate INSERT ALL syntax:
seed {
seed_customers(count: 1000, size: 100, object: customer)
`INSERT ALL __values__(customer(email, name, created_at))`
}Expands to:
INSERT ALL
INTO customer (email, name, created_at) VALUES ('user1@example.com', 'Alice', '2023-06-15T10:30:00Z')
INTO customer (email, name, created_at) VALUES ('user2@example.com', 'Bob', '2021-03-22T14:15:00Z')
SELECT 1 FROM DUALCassandra#
Cassandra uses column-oriented VALUES where each column’s values are grouped together:
INSERT INTO customer (email, name, created_at)
VALUES ('user1@example.com','user2@example.com', 'Alice','Bob', '2023-06-15T10:30:00Z','2021-03-22T14:15:00Z')MongoDB#
MongoDB does not use __values__ or __columns__. Queries use MongoDB command syntax with positional placeholders:
seed {
seed_customers(count: 1000, size: 100, object: customer)
`{"insert": "customer", "documents": [{"email": "$1", "name": "$2", "created_at": "$3"}]}` (
field('email'),
field('name'),
field('created_at')
)
}Each batch row expands into a separate document insert.
Cross-object references#
Object fields can reference other objects. This is useful when a child object needs a value from a parent. Here order_item evaluates product as a nested object, then uses dot-access on the result to compute price.
object product {
name = gen('productname')
price = uniform.int(1.00, 50.00)
}
object order_item {
product = obj('product')
quantity = gen('number:1,10')
price = float(field('product').price) * float(field('quantity'))
}Sub-objects#
Sub-objects let a parent object generate a variable number of child instances inline, then aggregate over them. This is useful when a parent field (like a purchase total) must be derived from its children.
Define sub-objects under the __sub__ key:
object item {
product = ref('products')
quantity = gen('number:1,5')
price = round(float(field('product').price) * float(field('quantity')), 2)
}
object purchase {
id = uuid_v4()
ordered_at = timestamp('2020-01-01T00:00:00Z', '2025-01-01T00:00:00Z')
total = sum(field('items'), 'price')
sub {
items = obj_n('item', 1, 10)
}
}obj_n('item', 1, 10) generates between 1 and 10 independent item instances (uniform distribution). Each evaluation produces fresh random values.
Distribution-controlled counts#
Every distribution namespace supports obj_n, letting you control how many sub-objects are generated per parent:
| Distribution | Signature | Description |
|---|---|---|
beta.obj_n | beta.obj_n('item', alpha, beta, min, max) | Beta-distributed count |
binomial.obj_n | binomial.obj_n('item', n, p, min, max) | Binomial-distributed count |
empirical.obj_n | empirical.obj_n('item', [samples], min, max) | Empirical-distributed count |
exp.obj_n | exp.obj_n('item', rate, min, max) | Exponential-distributed count |
gamma.obj_n | gamma.obj_n('item', shape, rate, min, max) | Gamma-distributed count |
lognorm.obj_n | lognorm.obj_n('item', mu, sigma, min, max) | Log-normal-distributed count |
norm.obj_n | norm.obj_n('item', mean, stddev, min, max) | Normal-distributed count |
pareto.obj_n | pareto.obj_n('item', alpha, min, max) | Pareto-distributed count |
poisson.obj_n | poisson.obj_n('item', lambda, min, max) | Poisson-distributed count |
uniform.obj_n | uniform.obj_n('item', min, max) | Uniform count in [min, max] (default) |
weibull.obj_n | weibull.obj_n('item', shape, scale, min, max) | Weibull-distributed count |
zipf.obj_n | zipf.obj_n('item', s, v, min, max) | Zipfian-distributed count |
Bare obj_n(...) is shorthand for uniform.obj_n(...).
__sub__can appear anywhere in the object definition. edg automatically reorders sub-fields to evaluate before any field that depends on them, so declaration order does not matter.
How sub-objects behave#
- Sub-fields are excluded from SQL args (they don’t appear in
$1, $2, ...or__columns__). - The parent query inserts only its regular fields. Sub-items are captured separately.
- Regular fields can reference sub-fields using
field()and aggregate functions.
Aggregate functions#
Use these to compute parent fields from sub-object collections:
| Function | Description |
|---|---|
avg(collection, 'expr') | Average of expression across all rows |
count(collection) | Number of rows |
distinct(collection, 'field') | Number of distinct values |
max(collection, 'expr') | Maximum value |
median(collection, 'expr') | Median, equivalent to percentile(collection, 'expr', 50) |
min(collection, 'expr') | Minimum value |
percentile(collection, 'expr', p) | pth percentile, linearly interpolated between the two nearest ranks. p is in [0, 100] |
stddev(collection, 'expr') | Population standard deviation (divides by N) |
sum(collection, 'expr') | Sum of expression across all rows |
variance(collection, 'expr') | Population variance (divides by N) |
The collection argument can be a field() reference to a sub-object or a dataset name string. The expression string is evaluated per-row using the full edg expression library, so any registered function (round, float, int, math functions, etc.) is available inside the expression. Compound expressions like 'price * quantity' and 'round(price * quantity, 2)' both work.
total = sum(field('items'), 'price')
rounded_total = round(sum(field('items'), 'price * quantity'), 2)
average_item = avg(field('items'), 'price')
cheapest = min(field('items'), 'price')
item_count = count(field('items'))Captured sub-items#
When a query with sub-objects runs in the seed section, edg captures each sub-item and makes it available as a dataset named {query_name}__{sub_field_name}. Each captured row includes a __parent__ key containing the parent’s non-sub field values.
For example, given this seed step:
seed {
seed_purchases(count: 1000, size: 100, object: purchase)
`INSERT INTO purchase (customer_id, total, ordered_at)
__values__` (
ref('fetch_customers').id,
field('total'),
field('ordered_at')
)
}After seed_purchases completes, the dataset seed_purchases__items is available in the environment. Each row looks like:
{
"product": {"id": "...", "name": "Latte", "price": 3.50},
"quantity": 3,
"price": 10.50,
"__parent__": {
"id": "a1b2c3...",
"ordered_at": "2023-07-14T09:30:00Z",
"total": 42.00
}
}The __parent__ key enables subsequent queries to link child rows back to their parent via the correct foreign key.
Seeding parent-child data#
When seeding parent-child relationships (e.g. purchases and their line items), the parent total must match the sum of its children. There are two approaches depending on whether you control the parent’s primary key.
When to use which approach#
| Scenario | Approach | Why |
|---|---|---|
Parent PK is object-generated (e.g. uuid_v4()) | Use captured sub-items directly | Each child’s __parent__ has the correct FK. Totals match by construction. No UPDATE needed. |
Parent PK is database-generated (e.g. DEFAULT gen_random_uuid()) | Seed independently + UPDATE | The object-generated ID won’t match the DB-generated one, so __parent__ can’t be used for FKs. Recalculate totals after seeding. |
| Run queries (single parent per execution) | Use __sub__ total directly | Each execution is self-contained. The total is always accurate. |
Here’s how they all work.
Seed: captured sub-items (recommended)#
When the parent seed query inserts the object-generated ID (via field('id')), each captured sub-item’s __parent__.id matches the actual database row. The child seed query can reference the captured dataset directly; correct FKs and totals by construction.
After a parent seed query runs, edg makes its sub-items available as {query_name}__{sub_field_name}. Use count() for the batch count and ref_each() to iterate:
object purchase_item {
product = ref('products')
quantity = gen('number:1,5')
price = round(float(field('product').price) * float(field('quantity')), 2)
}
object purchase {
id = uuid_v4()
ordered_at = timestamp('2020-01-01T00:00:00Z', '2025-01-01T00:00:00Z')
total = sum(field('items'), 'price')
sub {
items = obj_n('purchase_item', items_per_purchase_min, items_per_purchase_max)
}
}
seed {
# Insert purchases with the object-generated UUID.
seed_purchases(count: purchases, size: 100, object: purchase)
`INSERT INTO purchase (id, customer_id, total, ordered_at)
__values__` (
field('id'),
ref('fetch_customers').id,
field('total'),
field('ordered_at')
)
# Insert the captured sub-items. count() returns the exact number
# of items generated. ref_each() iterates one per row, and
# __parent__.id links back to the correct purchase.
seed_purchase_items(count: count(seed_purchases__items), size: 100)
`INSERT INTO purchase_item (purchase_id, product_id, quantity, price)
__values__` (
ref_each(seed_purchases__items).__parent__.id,
ref_each(seed_purchases__items).product.id,
ref_each(seed_purchases__items).quantity,
ref_each(seed_purchases__items).price
)
}The parent query MUST insert the object-generated ID (e.g.
field('id')) rather than relying on database defaults. Otherwise__parent__.idwon’t match the actual database row’s primary key.
Seed: independent children with post-seed UPDATE (fallback)#
When you can’t control the parent’s primary key (e.g. the database generates it), use this approach instead. Children are assigned to random parents via ref, then totals are recalculated:
seed {
seed_purchases(count: purchases, size: 100, object: purchase)
`INSERT INTO purchase (customer_id, total, ordered_at)
__values__` (
ref('fetch_customers').id,
field('total'),
field('ordered_at')
)
fetch_purchases `SELECT id FROM purchase`
seed_purchase_items(count: purchases * 3, size: 100, object: purchase_item)
`INSERT INTO purchase_item (purchase_id, product_id, quantity, price)
__values__` (
ref('fetch_purchases').id,
field('product').id,
field('quantity'),
field('price')
)
update_purchase_totals `UPDATE purchase SET total = COALESCE((
SELECT SUM(pi.price)
FROM purchase_item pi
WHERE pi.purchase_id = purchase.id
), 0)`
}Run: accurate total via __sub__#
In run queries, each execution generates one object instance. The __sub__ total is computed from the exact children being inserted, so it’s always accurate. Use a CTE to insert the parent and children together:
run {
insert_purchase(object: purchase) `
WITH p AS (
INSERT INTO purchase (customer_id, total, ordered_at)
VALUES ($1, $2, $3)
RETURNING id
)
INSERT INTO purchase_item (purchase_id, product_id, quantity, price)
SELECT p.id, $4, $5, $6
FROM p` (
ref('fetch_customer_ids').id,
field('total'),
field('ordered_at'),
ref('fetch_product_ids').id,
uniform.int(1, 5)),
ref_same('fetch_product_ids').price * float(arg(4))
)
}Complete example#
A coffee shop workload using captured sub-items for seed and __sub__ totals for run:
let customers = int(coalesce(env_nil('CUSTOMER_COUNT'), 500))
let purchases = int(coalesce(env_nil('ORDER_COUNT'), 10000))
let fetch_limit = int(coalesce(env_nil('FETCH_LIMIT'), 5000))
let items_per_purchase_min = 1
let items_per_purchase_max = 10
ref products [
{id: "05f47bf4-5ded-42cc-828e-3251b0c7ab11", name: "Americano", price: 3.10}
{id: "1d6bffe0-4b90-41a6-bb6b-ca45286447c4", name: "Latte", price: 3.50}
{id: "2aa1183f-949b-4ba4-ab47-1123faf93e5a", name: "Cortado", price: 3.30}
{id: "3c3e8a1d-62f5-4d9c-b1a7-9e4f2d8c5b3a", name: "Cappuccino", price: 3.80}
{id: "49d4f2e6-8b31-47c5-9e0a-1f6d3c7b5e2d", name: "Flat White", price: 3.70}
{id: "52e7c4a8-15d9-4f63-a8b2-6e1d9f3c7a5b", name: "Espresso", price: 2.50}
{id: "65f1a3c7-49e2-4b86-91d4-8c2e6f0a3d7b", name: "Mocha", price: 4.20}
{id: "78c2d6f0-73a5-4e19-b5c8-3a7d1e9f4b6c", name: "Cold Brew", price: 3.90}
{id: "80a4e8c2-96b7-4d52-c3e6-5b9f2a1d7c8e", name: "Macchiato", price: 3.40}
{id: "96b9d3f7-28a1-4e85-d7f0-4c8e1b5a9d2f", name: "Pour Over", price: 4.50}
]
object customer {
email = gen('email')
}
object purchase_item {
product = ref('products')
quantity = gen('number:1,5')
price = round(float(field('product').price) * float(field('quantity')), 2)
}
object purchase {
id = uuid_v4()
name = gen('productname')
ordered_at = timestamp('2020-01-01T00:00:00Z', '2025-01-01T00:00:00Z')
total = round(sum(field('items'), 'price'), 2)
sub {
items = obj_n('purchase_item', items_per_purchase_min, items_per_purchase_max)
}
}
up {
create_customer `CREATE TABLE IF NOT EXISTS customer (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
email STRING NOT NULL
)`
create_product `CREATE TABLE IF NOT EXISTS product (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name STRING NOT NULL,
price DECIMAL NOT NULL
)`
create_purchase `CREATE TABLE IF NOT EXISTS purchase (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
customer_id UUID NOT NULL REFERENCES customer (id),
total DECIMAL NOT NULL,
ordered_at TIMESTAMPTZ NOT NULL
)`
create_purchase_item `CREATE TABLE IF NOT EXISTS purchase_item (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
purchase_id UUID NOT NULL REFERENCES purchase (id),
product_id UUID NOT NULL REFERENCES product (id),
quantity INT NOT NULL,
total DECIMAL NOT NULL
)`
}
seed {
seed_products(count: 10, size: 10)
`INSERT INTO product (id, name, price)
__values__` (
ref_each(products).id,
ref_each(products).name,
ref_each(products).price
)
seed_customers(count: customers, size: 100, object: customer)
`INSERT INTO customer (email)
__values__`
fetch_customers `SELECT id FROM customer`
fetch_products `SELECT id, name, price FROM product`
seed_purchases(count: purchases, size: 100, object: purchase)
`INSERT INTO purchase (id, customer_id, total, ordered_at)
__values__` (
field('id'),
ref('fetch_customers').id,
field('total'),
field('ordered_at')
)
seed_purchase_items(count: count(seed_purchases__items), size: 100)
`INSERT INTO purchase_item (purchase_id, product_id, quantity, total)
__values__` (
ref_each(seed_purchases__items).__parent__.id,
ref_each(seed_purchases__items).product.id,
ref_each(seed_purchases__items).quantity,
ref_each(seed_purchases__items).price
)
}Verify totals match after seeding by fetching the first 5 products, their totals, and their product_items:
SELECT
p.id,
ROUND(p.total::NUMERIC, 2) AS purchase_total,
COUNT(pi.id) AS item_count,
ROUND(SUM(pi.total)::NUMERIC, 2) AS items_sum,
ROUND(p.total::NUMERIC, 2) = ROUND(SUM(pi.total)::NUMERIC, 2) AS matches
FROM purchase p
JOIN purchase_item pi ON p.id = pi.purchase_id
WHERE p.id IN (SELECT id FROM purchase LIMIT 5)
GROUP BY p.id, p.total;
id | purchase_total | item_count | items_sum | matches
--------------+----------------+------------+-----------+----------
001c...48b4 | 77.30 | 7 | 77.30 | t
001d...9454 | 113.40 | 10 | 113.40 | t
0013...323e | 43.80 | 4 | 43.80 | t
0012...5aa0 | 72.70 | 8 | 72.70 | t
0022...3642 | 7.50 | 1 | 7.50 | t