edg-lang#
This page is a high-level overview of edg-lang. For a deeper dive into the syntax and semantics, head over to the edg-lang spec.
edg workloads are configured with a purpose-built DSL (Domain-Specific Language) called edg-lang. Use .edg files to define your workloads.
edg run --config workload.edg --url $DATABASE_URLUse edg fmt to auto-format .edg files with canonical indentation and spacing. See CLI Reference > Formatting for details.
Quick example#
let users = 10000
let batch_size = 1000
let fetch_limit = batch_size
up {
create_users `CREATE TABLE IF NOT EXISTS users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
email STRING NOT NULL
)`
}
seed {
populate_users(count: users, size: batch_size)
`INSERT INTO users (email) __values__` (
gen('email')
)
fetch_users
`SELECT id, email FROM users LIMIT $1` (
fetch_limit
)
}
run {
get_user
`SELECT * FROM users WHERE id = $1::UUID` (
ref('fetch_users').id
)
update_email
`UPDATE users SET email = $2 WHERE id = $1::UUID` (
ref('fetch_users').id, gen('email')
)
}
deseed {
truncate_users `TRUNCATE TABLE users CASCADE`
}
down {
drop_users `DROP TABLE IF EXISTS users`
}Syntax reference#
Includes#
Use include to merge the contents of another .edg file directly into the current file. Unlike import, includes do not require pub and do not namespace declarations; everything is merged as-is:
shared/globals.edg
let batch_size = 100
let count = 1000shared/schema.edg
up {
create_users `CREATE TABLE users (id INT, email TEXT)`
}main.edg
include 'shared/globals.edg'
include 'shared/schema.edg'
seed {
populate_users(count: count, size: batch_size)
`INSERT INTO users (email) __values__` (gen('email'))
}Includes must appear before all other declarations. Circular includes are detected and produce an error. Paths are resolved relative to the including file.
Use include for simple file splitting where you want flat merging. Use import when you need namespacing and visibility control via pub.
Imports#
Use import to share declarations across .edg files. Mark declarations as public with pub to make them visible to importing files:
top_level.edg
pub let customers = 100000
pub let initial_balance = 10000
pub let batch_size = 10000objects/customer.edg
pub object customer {
email = gen('email')
name = gen('name')
created_at = timestamp('2020-01-01T00:00:00Z', '2024-01-01T00:00:00Z')
}Importing file
import 'top_level.edg'
import 'objects/customer.edg' as cust
seed {
populate_customer(count: top.customers, size: top.batch_size, object: cust.customer)
`INSERT INTO customer __columns__ __values__`
}The filename (minus .edg) becomes the default namespace. Use as to set a custom namespace:
import 'shared/long_filename.edg' as cfg
# cfg.customers, cfg.batch_size, etc.The pub keyword works with:
| Declaration | Example |
|---|---|
pub let | Global variables |
pub object | Data generation objects |
pub ref | Reference datasets |
pub expr | Expression definitions |
pub seq | Sequence definitions |
pub template | Query templates |
Only pub-marked declarations are visible to importing files. Everything else stays private.
Imports must appear before all other declarations. Circular imports are detected and produce an error.
See examples/imports/ for a complete working example.
Globals#
Use let to declare global variables. Later globals can reference earlier ones, and expression-valued globals are compiled at startup:
let warehouses = 1
let districts = warehouses * 10
let customers = int(coalesce(env_nil('CUSTOMERS'), 30000))Objects#
Objects define reusable arg templates. Fields are bound to query parameters in declaration order:
object customer {
email = gen('email')
name = gen('name')
created_at = timestamp('2020-01-01T00:00:00Z', '2024-01-01T00:00:00Z')
}Sub-collections use the sub keyword:
object purchase {
id = uuid_v4()
name = gen('productname')
total = round(sum(field('items'), 'price'), 2)
sub {
items = obj_n('purchase_item', 1, 10)
}
}Sub fields are evaluated before regular fields.
Reference data#
Static datasets for ref_rand, ref_same, etc.:
ref products [
{id: "abc-123", name: "Americano", price: 3.10}
{id: "def-456", name: "Latte", price: 3.50}
{id: "ghi-789", name: "Cortado", price: 3.30}
]Values can be strings, numbers, booleans, or arrays:
ref regions [
{name: "us", cities: ["new york", "chicago", "la"]}
{name: "eu", cities: ["london", "paris", "berlin"]}
]Signals PRO#
Pre-computed value buffers for correlated temporal patterns. Define a signal with an expression that’s evaluated for each step of the buffer, then consume it with signal(), signal_at(), or signal_correlated() in query args.
Two definition styles:
Duration-based:
signal traffic(from: '2024-01-01T00:00:00Z', to: '2024-01-08T00:00:00Z', interval: '5m') {
1000 + 400 * sin(2 * 3.14159 * i / 288)
}Length is auto-computed from (to - from) / interval. The interval also enables duration-based lag in signal_correlated.
Length-based:
signal custom_wave(length: 1000) {
sin(2 * 3.14159 * i / 100)
}Direct control over buffer size. Consume signals in query args:
| Function | Description |
|---|---|
signal('name') | Value at current iteration (wraps around) |
signal_at('name', index) | Value at explicit index |
signal_correlated('name', lag, correlation) | Correlated value with lag and noise |
Lag can be an integer (iterations) or a duration string like '2h' (requires the signal to have an interval). Correlation ranges from -1.0 to 1.0.
run {
insert_page_view `INSERT INTO page_views (ts, count) VALUES ($1, $2)` (
now(),
int(signal('traffic'))
)
insert_ticket `INSERT INTO support_tickets (ts, count) VALUES ($1, $2)` (
now(),
int(signal_correlated('traffic', '2h', 0.3))
)
}See Correlated Multi-Table Signals for full documentation.
Seasons PRO#
Weighted timestamp distributions. Where a signal produces a value per iteration, a season answers “when did this row happen?” with a distribution that has calendar structure.
season retail(from: '2023-01-01T00:00:00Z', to: '2025-01-01T00:00:00Z', bucket: '1h') {
months = [0.8, 0.7, 0.9, 0.9, 1, 0.9, 0.9, 1, 1, 1.2, 1.8, 2.5]
weekdays = [1.1, 1, 1, 1, 1, 1.2, 1.3]
hours = [0.2, 0.1, 0.1, 0.1, 0.1, 0.2, 0.4, 0.7, 1, 1.2, 1.3, 1.3, 1.2, 1.2, 1.3, 1.3, 1.4, 1.6, 1.8, 1.7, 1.4, 1, 0.6, 0.3]
recency = { half_life: '8760h' }
spikes = [{ at: '2024-11-29T00:00:00Z', width: '96h', mag: 12 }]
weight = month == 12 && day > 25 ? 0.3 : 1
}Every term is a multiplier, so they compose into one curve.
| Term | Description |
|---|---|
months | 12 weights, index 0 is January |
weekdays | 7 weights, index 0 is Sunday |
hours | 24 weights, index 0 is midnight |
business_hours | { from, to, off }, hours outside [from, to) weigh off, so off: 0.05 is 20x less likely than a business hour |
recency | { half_life }, exponential decay towards the past |
spikes | { at, width, mag } peaks tapering linearly to 1 |
weight | Arbitrary expression evaluated per bucket, with t, year, month, day, yearday, weekday and hour in scope |
Consume seasons in query args:
| Function | Description |
|---|---|
season('name') | RFC3339 timestamp drawn from the distribution |
season_weight('name', ts) | Intensity at ts, mean 1.0 across the range |
seed {
orders(type: exec_batch, count: 20000, size: 1000)
`INSERT INTO orders (created_at, total) __values__` (
created_at: season('retail'),
total: uniform.float(10, 500, 2) * season_weight('retail', arg('created_at'))
)
}bucket is optional and defaults to the coarsest width that still resolves every declared term.
See Seasonal Timestamp Distributions for full documentation.
Queries#
The core syntax is name, optional options, SQL in backticks, optional args:
query_name `SELECT * FROM users WHERE id = $1` (ref('fetch_users').id)Breaking that down:
| Part | Syntax | Maps to |
|---|---|---|
| Name | bare identifier | name: |
| Options | (key: value, ...) after name | count:, size:, object:, type:, template:, prepared:, batch_format: |
| SQL | backtick-delimited string | query: |
| Args | (expr, ...) after SQL | args: |
Query type is inferred from the SQL verb (SELECT -> query, INSERT/CREATE/DROP -> exec). Override with type: in options when needed.
Simple queries (no args)#
up {
create_users `CREATE TABLE IF NOT EXISTS users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
email STRING NOT NULL
)`
}Queries with args#
Args follow the SQL in parentheses. Multiline is fine:
run {
insert_purchase `INSERT INTO purchase VALUES ($1, $2, $3)` (
ref('fetch_customer_ids').id,
uniform.int(1, 10)),
timestamp('2020-01-01T00:00:00Z', '2025-01-01T00:00:00Z')
)
}Named args#
Use key: expr syntax in the args list:
init {
fetch_users `SELECT id, email FROM users LIMIT $1` (limit: fetch_limit)
}Batch queries with options#
Options go in parentheses right after the name:
seed {
populate_users(count: users, size: batch_size, object: customer)
`INSERT INTO users (email) __values__`
}Available options:
| Option | Description |
|---|---|
count | Number of rows to generate |
size | Batch size |
object | Reference to an object definition |
type | Override inferred query type (query, exec, exec_batch, query_batch) |
template | Reference to a template definition |
prepared | Use prepared statements (true/false) |
batch_format | Batch formatting template |
Sections#
All lifecycle sections use the same block syntax:
up {
create_table `CREATE TABLE t (id INT PRIMARY KEY)`
}
seed {
insert_data `INSERT INTO t VALUES ($1)` (gen('email'))
}
init {
fetch_ids `SELECT id FROM t`
}
run {
read_row `SELECT * FROM t WHERE id = $1` (ref('fetch_ids').id)
}
deseed {
truncate_t `TRUNCATE TABLE t CASCADE`
}
down {
drop_t `DROP TABLE IF EXISTS t`
}Inline single-query sections work too:
deseed { truncate_users `TRUNCATE TABLE users CASCADE` }Transactions#
Use the transaction keyword inside run:
run {
transaction delete_insert {
let rid = gen('number:1,' + string(records))
let new_k = gen('number:0,10000')
let new_c = regex('[a-zA-Z0-9]{120}')
delete_row `DELETE FROM sbtest1 WHERE id = ?` (local('rid'))
insert_row `INSERT INTO sbtest1 (id, k, c) VALUES (?, ?, ?)` (
local('rid'), local('new_k'), local('new_c')
)
}
}Transaction options go in parentheses after the name:
run {
transaction make_transfer(wait: 2s, ignore: true, ignore_nested: false) {
read_source `SELECT balance FROM account WHERE id = 1`
}
}| Option | Type | Default | Description |
|---|---|---|---|
wait | duration | – | Pause after each successful execution. |
ignore | bool | false | Hide from progress output and summary table. Still collected for Prometheus and expectations. |
ignore_nested | bool | true | Hide queries inside if/then/else and match/when/default blocks from progress output and summary table. Set to false to display them individually. |
Locals are declared with let. Inside a transaction block, they are scoped to the transaction and evaluated once at the start. On standalone queries, they appear between the SQL template and the args block and are evaluated per row. Reference them with local('name') in query args.
Stats#
Periodic observability queries that run at a fixed rate alongside the workload:
stats (rate: 1 / 1s) {
node (type: bar) (
post_print: { key: 'id', value: result().node_id }
) `SHOW node_id`
}The block rate is required and applies to every query that doesn’t set its own. type picks the chart drawn in the TUI Stats view - line (default) for a numeric value over time, bar for the distribution of a categorical one. Add include: true to the block to also show the queries in the progress table.
See run, weights, and workers and TUI for the full behaviour.
Weights#
weights {
insert_purchase = 30
get_customer_purchases = 70
}Expectations PRO#
expect {
error_rate < 1
p99 < 100
}Comments#
Lines starting with # are comments:
# Connection pool test workload
let pool_size = 50
run {
# Simple health check
ping `SELECT 1`
}Complete example#
A coffee shop e-commerce workload:
let customers = int(coalesce(env_nil('CUSTOMER_COUNT'), 500))
let purchases = int(coalesce(env_nil('ORDER_COUNT'), 10000))
let items_per_purchase_min = 1
let items_per_purchase_max = 10
ref products [
{id: "03330d18-c48d-48ee-a867-afbbba916a27", name: "Americano", price: 3.10}
{id: "174d02eb-40c9-433b-bf63-21a7d7f433e0", name: "Latte", price: 3.50}
{id: "22ba5bf6-f33c-4e29-bc55-aeefe7fb5d9c", name: "Cortado", price: 3.30}
{id: "32075b3f-f7d3-4060-991c-d7ac648353cb", name: "Cappuccino", price: 3.80}
{id: "489b6bc4-ec7b-4310-9cda-b7d2ed4d5e55", name: "Flat White", price: 3.70}
{id: "57a5bd12-89a1-4087-9e24-8a260fdc07f7", name: "Espresso", price: 2.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
)`
}
seed {
seed_products(count: 6, size: 6)
`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`
}
weights {
insert_purchase = 30
get_customer_purchases = 70
}
init {
fetch_customer_ids `SELECT id FROM customer LIMIT $1` (limit: 5000)
fetch_product_ids `SELECT id, price FROM product LIMIT $1` (limit: 5000)
}
run {
insert_purchase `
WITH p AS (
INSERT INTO purchase (customer_id, total, ordered_at)
VALUES ($1, $5, $6)
RETURNING id
)
INSERT INTO purchase_item (purchase_id, product_id, quantity, total)
SELECT p.id, $2, $4, $5
FROM p` (
ref('fetch_customer_ids').id,
ref('fetch_product_ids').id,
ref_same('fetch_product_ids').price,
uniform.int(items_per_purchase_min, items_per_purchase_max)),
arg(2) * float(arg(3)),
timestamp('2020-01-01T00:00:00Z', '2025-01-01T00:00:00Z')
)
get_customer_purchases `
SELECT p.id, p.total, p.ordered_at
FROM purchase p
WHERE p.customer_id = $1
ORDER BY p.ordered_at DESC
LIMIT 10 ` (
ref('fetch_customer_ids').id
)
}
deseed {
truncate_purchase `TRUNCATE TABLE purchase CASCADE`
truncate_product `TRUNCATE TABLE product CASCADE`
truncate_customer `TRUNCATE TABLE customer CASCADE`
}
down {
drop_purchase `DROP TABLE IF EXISTS purchase`
drop_product `DROP TABLE IF EXISTS product`
drop_customer `DROP TABLE IF EXISTS customer`
}Editor support#
A VSCode extension is available for .edg files. It provides:
- Syntax highlighting (keywords, built-in functions, placeholders)
- Embedded SQL highlighting inside backtick-delimited raw strings
- Bracket matching and auto-closing
- Comment toggling
- LSP features (completions, hover docs, go-to-definition)
Install from the marketplace:
code --install-extension RobReid.edg-langOr search for “edg-lang” in the VSCode Extensions panel.