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 is configurable via both YAML and a purpose-built DSL (Domain-Specific Language) called edg-lang. edg-lang produces the same internal representation as YAML and all features, validation, and runtime behaviour are identical. Simply use .edg files instead of .yaml.

edg run --config workload.edg --url $DATABASE_URL

Use edg fmt to auto-format .edg files with canonical indentation and spacing. See CLI Reference > Formatting for details.

Why edg-lang?#

YAML is verbose a finicky. Every query in an edg workload needs name:, type:, query: |-, and args: with careful indentation when configured with YAML. edg-lang cuts config size and provides syntax highlighting and autocompletion in vscode thanks to its editor support:

globals:
  users: 10000
  batch_size: 1000
  fetch_limit: batch_size

up:
  - name: create_users
    query: |-
      CREATE TABLE IF NOT EXISTS users (
        id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
        email STRING NOT NULL
      )

seed:
  - name: populate_users
    type: exec_batch
    count: users
    size: batch_size
    args:
      - gen('email')
    query: |-
      INSERT INTO users (email)
      __values__

init:
  - name: fetch_users
    args:
      - fetch_limit
    query: SELECT id, email FROM users LIMIT $1

run:
  - name: get_user
    args:
      - ref_rand('fetch_users').id
    query: |-
      SELECT * FROM users WHERE id = $1::UUID

  - name: update_email
    args:
      - ref_rand('fetch_users').id
      - gen('email')
    query: |-
      UPDATE users SET email = $2 WHERE id = $1::UUID

deseed:
  - name: truncate_users
    type: exec
    query: TRUNCATE TABLE users CASCADE

down:
  - name: drop_users
    type: exec
    query: DROP TABLE IF EXISTS users
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_rand('fetch_users').id
    )
  
  update_email
    `UPDATE users SET email = $2 WHERE id = $1::UUID` (
      ref_rand('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 = 1000

shared/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.

include is the edg-lang equivalent of YAML’s !include directive. 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      = 10000

objects/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:

DeclarationExample
pub letGlobal variables
pub objectData generation objects
pub refReference datasets
pub exprExpression definitions
pub seqSequence definitions
pub templateQuery 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.

YAML uses !include for a similar purpose. See Includes / Imports for details.

See examples/imports/ for a complete working example.

Globals#

Use let to declare global variables. These work exactly like the YAML globals: section; 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))
globals:
  warehouses: 1
  districts: warehouses * 10
  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')
}
objects:
  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)
  }
}
objects:
  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, matching the YAML __sub__ behaviour.

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}
]
reference:
  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"]}
]
reference:
  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:

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.

signal custom_wave(length: 1000) {
  sin(2 * 3.14159 * i / 100)
}

Direct control over buffer size.

YAML equivalents:

signals:
  traffic:
    from: '2024-01-01T00:00:00Z'
    to: '2024-01-08T00:00:00Z'
    interval: 5m
    expr: 1000 + 400 * sin(2 * 3.14159 * i / 288)
signals:
  custom_wave:
    length: 1000
    expr: sin(2 * 3.14159 * i / 100)

Consume signals in query args:

FunctionDescription
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.

Queries#

The core syntax is name, optional options, SQL in backticks, optional args:

query_name `SELECT * FROM users WHERE id = $1` (ref_rand('fetch_users').id)

Breaking that down:

PartSyntaxMaps to
Namebare identifiername:
Options(key: value, ...) after namecount:, size:, object:, type:, template:, prepared:, batch_format:
SQLbacktick-delimited stringquery:
Args(expr, ...) after SQLargs:

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
  )`
}
up:
  - name: create_users
    query: |-
      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_rand('fetch_customer_ids').id,
    int(uniform(1, 10)),
    timestamp('2020-01-01T00:00:00Z', '2025-01-01T00:00:00Z')
  )
}
run:
  - name: insert_purchase
    args:
      - ref_rand('fetch_customer_ids').id
      - int(uniform(1, 10))
      - timestamp('2020-01-01T00:00:00Z', '2025-01-01T00:00:00Z')
    query: |-
      INSERT INTO purchase VALUES ($1, $2, $3)

Named args#

Use key: expr syntax in the args list:

init {
  fetch_users `SELECT id, email FROM users LIMIT $1` (limit: fetch_limit)
}
init:
  - name: fetch_users
    args:
      limit: fetch_limit
    query: SELECT id, email FROM users LIMIT $1

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__`
}
seed:
  - name: populate_users
    count: users
    size: batch_size
    object: customer
    query: |-
      INSERT INTO users (email) __values__

Available options:

OptionDescription
countNumber of rows to generate
sizeBatch size
objectReference to an object definition
typeOverride inferred query type (query, exec, exec_batch, query_batch)
templateReference to a template definition
preparedUse prepared statements (true/false)
batch_formatBatch 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_rand('fetch_ids').id)
}

deseed {
  truncate_t `TRUNCATE TABLE t CASCADE`
}

down {
  drop_t `DROP TABLE IF EXISTS t`
}
up:
  - name: create_table
    query: CREATE TABLE t (id INT PRIMARY KEY)

seed:
  - name: insert_data
    args: [gen('email')]
    query: INSERT INTO t VALUES ($1)

init:
  - name: fetch_ids
    query: SELECT id FROM t

run:
  - name: read_row
    args: [ref_rand('fetch_ids').id]
    query: SELECT * FROM t WHERE id = $1

deseed:
  - name: truncate_t
    type: exec
    query: TRUNCATE TABLE t CASCADE

down:
  - name: drop_t
    type: exec
    query: 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')
    )
  }
}
run:
  - transaction: delete_insert
    locals:
      rid: gen('number:1,' + string(records))
      new_k: gen('number:0,10000')
      new_c: regex('[a-zA-Z0-9]{120}')
    queries:
      - name: delete_row
        type: exec
        args: [local('rid')]
        query: DELETE FROM sbtest1 WHERE id = ?

      - name: insert_row
        type: exec
        args:
          - local('rid')
          - local('new_k')
          - local('new_c')
        query: INSERT INTO sbtest1 (id, k, c) VALUES (?, ?, ?)

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`
  }
}
run:
  - transaction: make_transfer
    wait: 2s
    ignore: true
    ignore_nested: false
    queries:
      - name: read_source
        type: query
        query: SELECT balance FROM account WHERE id = 1
OptionTypeDefaultDescription
waitdurationPause after each successful execution.
ignoreboolfalseHide from progress output and summary table. Still collected for Prometheus and expectations.
ignore_nestedbooltrueHide 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.

Weights#

weights {
  insert_purchase = 30
  get_customer_purchases = 70
}
run_weights:
  insert_purchase: 30
  get_customer_purchases: 70

Expectations#

expect {
  error_rate < 1
  p99 < 100
}
expectations:
  - error_rate < 1
  - p99 < 100

Comments#

Lines starting with # are comments (same syntax in both formats):

# Connection pool test workload
let pool_size = 50

run {
  # Simple health check
  ping `SELECT 1`
}
# Connection pool test workload
globals:
  pool_size: 50

run:
  # Simple health check
  - name: ping
    query: 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

object customer {
  email = gen('email')
}

object purchase_item {
  product = ref_rand('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)
  }
}

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}
]

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`
}

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_rand('fetch_customer_ids').id,
      ref_rand('fetch_product_ids').id,
      ref_same('fetch_product_ids').price,
      int(uniform(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_rand('fetch_customer_ids').id
    )
}

weights {
  insert_purchase = 30
  get_customer_purchases = 70
}

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`
}
globals:
  customers: int(coalesce(env_nil('CUSTOMER_COUNT'), 500))
  purchases: int(coalesce(env_nil('ORDER_COUNT'), 10000))
  items_per_purchase_min: 1
  items_per_purchase_max: 10

objects:
  customer:
    email: gen('email')

  purchase_item:
    product: ref_rand('products')
    quantity: gen('number:1,5')
    price: round(float(field('product').price) * float(field('quantity')), 2)

  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)

reference:
  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}

up:
  - name: create_customer
    query: |-
      CREATE TABLE IF NOT EXISTS customer (
        id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
        email STRING NOT NULL
      )
  - name: create_product
    query: |-
      CREATE TABLE IF NOT EXISTS product (
        id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
        name STRING NOT NULL,
        price DECIMAL NOT NULL
      )
  - name: create_purchase
    query: |-
      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:
  - name: seed_products
    count: 6
    size: 6
    args:
      - ref_each(products).id
      - ref_each(products).name
      - ref_each(products).price
    query: |-
      INSERT INTO product (id, name, price) __values__

  - name: seed_customers
    count: customers
    size: 100
    object: customer
    query: |-
      INSERT INTO customer (email) __values__

  - name: fetch_customers
    query: SELECT id FROM customer

init:
  - name: fetch_customer_ids
    args:
      limit: 5000
    query: SELECT id FROM customer LIMIT $1

  - name: fetch_product_ids
    args:
      limit: 5000
    query: SELECT id, price FROM product LIMIT $1

run:
  - name: insert_purchase
    args:
      - ref_rand('fetch_customer_ids').id
      - ref_rand('fetch_product_ids').id
      - ref_same('fetch_product_ids').price
      - int(uniform(items_per_purchase_min, items_per_purchase_max))
      - arg(2) * float(arg(3))
      - timestamp('2020-01-01T00:00:00Z', '2025-01-01T00:00:00Z')
    query: |-
      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

  - name: get_customer_purchases
    args:
      - ref_rand('fetch_customer_ids').id
    query: |-
      SELECT p.id, p.total, p.ordered_at
      FROM purchase p
      WHERE p.customer_id = $1
      ORDER BY p.ordered_at DESC
      LIMIT 10

run_weights:
  insert_purchase: 30
  get_customer_purchases: 70

deseed:
  - name: truncate_purchase
    type: exec
    query: TRUNCATE TABLE purchase CASCADE
  - name: truncate_product
    type: exec
    query: TRUNCATE TABLE product CASCADE
  - name: truncate_customer
    type: exec
    query: TRUNCATE TABLE customer CASCADE

down:
  - name: drop_purchase
    type: exec
    query: DROP TABLE IF EXISTS purchase
  - name: drop_product
    type: exec
    query: DROP TABLE IF EXISTS product
  - name: drop_customer
    type: exec
    query: DROP TABLE IF EXISTS customer

YAML and edg-lang together#

Both formats are fully supported. Pick whichever suits the workload:

  • Use YAML when you prefer !include for splitting configs, or when your team is more comfortable with YAML.
  • Use edg-lang for cleaner, more compact workloads with include for flat file merging, import/pub for namespaced sharing, and editor support (syntax highlighting, LSP).

Existing YAML configs continue to work unchanged. The format is detected by file extension: .edg -> edg-lang, .yaml/.yml -> YAML.

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-lang

Or search for “edg-lang” in the VSCode Extensions panel.