一键导入
edg-config
Generate or modify edg workload configurations (YAML or DSL) from a natural language description of the desired schema and workload.
用 Codex 或 Claude 帮你安装 复制这段 Prompt,粘贴到 Codex、Claude 或其他助手里,让它检查 Skill 页面并帮你完成安装。
菜单
Generate or modify edg workload configurations (YAML or DSL) from a natural language description of the desired schema and workload.
用 Codex 或 Claude 帮你安装 复制这段 Prompt,粘贴到 Codex、Claude 或其他助手里,让它检查 Skill 页面并帮你完成安装。
基于 SOC 职业分类
Help compose edg expressions. Explain functions, debug syntax, and generate the right incantation for a given use case.
Convert an edg config between database drivers (e.g., pgx to mysql) or between formats (YAML to DSL, DSL to YAML).
Validate an edg config file (YAML or DSL), interpret errors, and suggest fixes.
| name | edg-config |
| description | Generate or modify edg workload configurations (YAML or DSL) from a natural language description of the desired schema and workload. |
| user-invocable | true |
You are an expert at creating edg (Expression-based Data Generator) workload configurations. When the user describes a database schema and workload, generate a complete, valid edg config file in either YAML or DSL format.
The user will describe:
.yaml) or DSL (.edg)If the user does not specify a driver, default to pgx.
If the user does not specify a format, default to YAML. Use DSL when the user explicitly requests it (e.g., "use DSL", "generate .edg", "use the new format") or when the workload is simple and query-heavy (no stages, conditionals, or LLM features).
examples/ that match the target driver and feature complexity (see Example Grounding below).workload.yaml or workload.edg in the working directory, or a user-specified path).edg validate config --config <path> and read the output.edg stage --config <path> --format csv -o ./preview
Before generating a config, read 1-2 example files from examples/ that match the user's request. This grounds your output in known-good configs.
File naming: examples/{feature}/{driver}.yaml - use crdb.yaml for pgx, mongodb.yaml for mongodb, cassandra.yaml for cassandra.
Match user request features to example directories:
| Feature | Example directory |
|---|---|
| Basic CRUD / minimal | minimal/, populate/ |
| Batch inserts | batch/ |
| Transactions | transaction/ |
| Background workers | workers/ |
| Event-driven hooks (HTTP/Kafka) | hooks/http, hooks/kafka |
| Staged workloads | stages/, stages_run_weights/ |
| Temporal patterns | temporal_patterns/ |
| Interval-aligned timestamps | timestamp_step/ |
| Reusable arg templates | objects/ |
| Social / relational models | social/ |
| E-commerce | ecommerce/ |
| Reference data / init | reference_data/, init/ |
| Conditional branching (if/match) | conditional_if/, conditional_match/ |
| Distributions (zipf, norm) | distributions/ |
| Named args | named_args/ |
| Print / live stats | print/ |
| Sync pairs | sync/ |
| Vectors / embeddings | vector/, embed/ |
| LLM structured generation | llm/ |
| Expectations / CI | expectations/ |
| Invoice line items | invoice_lines/ |
If the target driver doesn't have an example in that directory, read the crdb.yaml version and adapt the SQL dialect.
edg supports two equivalent config formats. The format is detected by file extension: .edg → DSL, .yaml/.yml → YAML.
Use YAML when:
if/match), print/post_print, seq: config, expressions: section, or complete: tool definitions - these are YAML-onlyUse DSL when:
.edg format| Feature | DSL | YAML |
|---|---|---|
Globals (let) | Yes | Yes |
Objects with sub | Yes | Yes |
Reference data (ref) | Yes | Yes |
| All lifecycle sections (up/seed/init/run/deseed/down) | Yes | Yes |
| Transactions with locals | Yes | Yes |
| Weights | Yes | Yes |
| Expectations | Yes | Yes |
| Workers | Yes | Yes |
| Hooks | Yes | Yes |
| Query options (count, size, object, type, template, prepared, batch_format, ignore, request_timeout, workers, rollback_if, print, post_print) | Yes | Yes |
| Named and positional args | Yes | Yes |
| Worker delay (one-shot) | Yes | Yes |
| Rollback_if (inline) | Yes | Yes |
| Stages | No | Yes |
| Conditionals (if/match) | No | Yes |
Global sequences (seq: config) | No | Yes |
| Print / post_print (inline) | Yes | Yes |
| Print / post_print (custom agg) | No | Yes |
| Expressions section | No | Yes |
| Complete section (LLM tools) | No | Yes |
# Globals
let users = 10000
let batch_size = 1000
# Objects
object customer {
email = gen('email')
name = gen('name')
sub {
items = obj_n('item', 1, 5)
}
}
# Reference data
ref products [
{id: "abc", name: "Latte", price: 3.50}
{id: "def", name: "Espresso", price: 2.50}
]
# Sections: name(options)? `SQL` (args)?
up {
create_users `CREATE TABLE IF NOT EXISTS users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
email STRING NOT NULL
)`
}
seed {
seed_users(count: users, size: batch_size, object: customer)
`INSERT INTO users (email) __values__`
fetch_users `SELECT id, email FROM users`
}
init {
load_users `SELECT id FROM users LIMIT $1` (limit: 5000)
}
run {
get_user `SELECT * FROM users WHERE id = $1` (ref_rand('load_users').id)
transaction transfer {
let amount = gen('number:1,100')
debit `UPDATE accounts SET balance = balance - $1 WHERE id = $2` (
local('amount'), ref_diff('accounts').id
)
credit `UPDATE accounts SET balance = balance + $1 WHERE id = $2` (
local('amount'), ref_rand('accounts').id
)
}
}
weights {
get_user = 70
transfer = 30
}
workers {
cleanup(rate: 1/10s) `DELETE FROM sessions WHERE expires_at < now()`
add_index(delay: 30s) `CREATE INDEX IF NOT EXISTS idx_email ON users (email)`
}
expect {
error_rate < 1
p99 < 100
}
deseed { truncate_users `TRUNCATE TABLE users CASCADE` }
down { drop_users `DROP TABLE IF EXISTS users` }
query: |-`SELECT ...` (arg1, arg2). The opening ( must be on the same line as the closing backtick - a newline before ( makes the parser treat it as a new query identifierquery_name(count: 100, size: 50) \SQL``type: in options#type: exec needed for DDL - it's inferredrate: or delay: as a query option: cleanup(rate: 1/10s) \SQL`ormigrate(delay: 30s) `SQL``A complete edg YAML config with all applicable sections:
globals for shared constants (row counts, batch sizes, worker counts)expressions for reusable computed values (optional, only if needed)reference for static lookup data (optional, only if needed)seq for named auto-incrementing sequences shared across all workers (optional, only if integer PKs needed)up for schema creation (CREATE TABLE statements)seed for data population (bulk INSERTs, use exec_batch with count/size for large volumes)init for fetching reference data needed by run queries (use type: query to populate named datasets)run for the transactional workload (the queries that will be benchmarked)stages for staged execution with per-stage worker counts, durations, and optional run_weights overridesrun_weights for weighted query selection (optional, only if multiple run queries; can also be set per-stage)workers for background queries that run on a fixed schedule alongside the main workload (optional)expectations for CI/CD assertions on benchmark results (optional)deseed for data cleanup (TRUNCATE statements)down for schema teardown (DROP TABLE statements)expectations to assert benchmark results (exit code 1 on failure):
expectations:
- error_rate < 1
- tpm > 5000
- query_name.p99 < 100
tpm, error_rate, query_name.p50, query_name.p95, query_name.p99, query_name.avg, query_name.qps, query_name.errorssuppress_errors: trueretries: N for automatic retry on transient errorstype: exec for INSERT, UPDATE, DELETE, TRUNCATE, DROP, CREATEtype: query for SELECT (returns rows, can populate named datasets)type: exec_batch for bulk INSERTs with count and size fieldstype: query_batch for bulk SELECTs with batch parameterstype defaults to exec$1, $2, etc. for query parameters (edg inlines values for non-pgx drivers automatically)uuid_v7() for sortable primary keysgen('pattern') for fake data using gofakeit patterns (e.g., gen('email'), gen('firstname'), gen('number:1,100'))regex('pattern') for random strings matching a regexuniform(min, max) / uniform_f(min, max, precision) for uniform random numbersnorm(mean, stddev, min, max) / norm_f(mean, stddev, min, max, precision) for normal distributionzipf(s, v, max) for hot-key / power-law workloadspareto(alpha, max) for continuous power-law distribution (lower values dominate)exp(rate, min, max) for exponential distributiontimestamp(min, max) for random timestampstimestamp_step() for the next monotonic timestamp (requires timestamp_steps in count:)timestamp_steps(min, max, interval_or_count) for the count of evenly spaced timestamps between min and max (use in count:); third arg is interval string ('5m') or integer count (10000). Sets up state for timestamp_step()date(format, min, max) for formatted datesbool() for random booleansseq(start, step) for auto-incrementing sequences (per worker)seq_alpha(length) for auto-incrementing alpha sequences per worker (aaa, aab, aac, ...)seq_global("name") for globally unique sequences shared across all workers (requires seq: config section)seq_alpha_global("name") for globally unique alpha sequences across all workers (requires seq: config with length)seq_rand("name") for uniform random picks from already-generated sequence valuesseq_zipf("name", s, v) / seq_pareto("name", alpha) / seq_norm("name", mean, stddev) / seq_exp("name", rate) / seq_lognorm("name", mu, sigma) for distribution-based picks from sequence valuesembed(text...) for real vector embeddings via an external API (OpenAI-compatible). Variadic - joins args with space. Requires a license and --embed-api-key or EDG_EMBED_API_KEY. Configure endpoint with --embed-url, model with --embed-model, dimensions with --embed-dimensions. Use for semantic similarity search with real embeddings instead of synthetic vector() clusterscomplete(tool_name, prompt) for LLM-generated structured data via tool calling. Returns a map; access fields with .field. Define tools in complete: YAML section. Use locals to call once per row and access multiple fields. Retries up to 3 times on missing/invalid tool calls, validates response types against schema. 120s per-request timeout. Requires a license and --complete-api-key or EDG_COMPLETE_API_KEY. Configure endpoint with --complete-url, model with --complete-model. Any OpenAI-compatible API works (Ollama, vLLM, etc.)complete_array(tool_name, prompt, count) for generating N structured items in a single LLM call. Returns []map; use with ref_each(local(...)) to iterate. Tool schema auto-wrapped in array request. Memoized by (tool, prompt, count). Same config flags and license requirement as complete()global_iter() for a monotonic iteration counter shared across all workers. Increments with every query execution. Use with math functions and globals to make data change shape over the life of a workload (temporal patterns)hook('name') for accessing a parsed body field by name inside hook query args. Requires parse_body: json on the hookabs(x), acos(x), asin(x), atan(x), atan2(y,x), ceil(x), cos(x), floor(x), log(x), log10(x), mod(x,y), pow(x,y), sin(x), sqrt(x), tan(x), and pi constant. Use with global_iter() for temporal patterns like drift, seasonality, spikes, and saturationseq: config section with seq_global("name"):
seq:
- name: order_id
start: 1
step: 1
seq_global("name") in args returns the next value from the named sequenceseq(start, step) which is per-worker, seq_global is shared across all workersseq_rand("name") (uniform) or distribution variants:
seq_zipf("name", s, v), seq_pareto("name", alpha), seq_norm("name", mean, stddev), seq_exp("name", rate), seq_lognorm("name", mu, sigma)start + index * step (no values stored in memory, works with any step)seq: with length:
seq:
- name: sku_code
length: 3
seq_alpha_global("name") returns the next alpha value. Length N gives 26^N possible values (length 3 = 17,576)seq_alpha(length) is the per-worker variant (no config section needed)init section with type: query to fetch data from seeded tables into named datasetsref_rand('dataset').field for random row access in run queriesref_same('dataset').field when multiple args need the same rowref_perm('dataset').field for worker-pinned rows (e.g., partition affinity)ref_diff('dataset').field for unique rows within a single query executionref_n('dataset', 'field', min, max) for N unique values as comma-separated stringdistribute_sum(total, minN, maxN, precision) partitions a total into N random parts (comma-separated) that sum exactly to it. Use SQL unnest/string_to_array (pgx) or JSON_TABLE (MySQL) to expand into rowsdistribute_weighted(total, weights, noise, precision) splits a total by proportional weights with controlled noise (0=exact, 1=fully random). Returns comma-separated values; use split_part (pgx) or SUBSTRING_INDEX (MySQL) to extract individual partsgen_locale('first_name', 'ja_JP') for locale-aware names, cities, streets, phones, zips, addressesgen_locale('name', 'de_DE') for full name in locale order (eastern = last+first, western = first last)en_US, ja_JP, de_DE, fr_FR, es_ES, pt_BR, zh_CN, ko_KR (aliases: ja, de, etc.)mask(value) for deterministic hex pseudonymization (16 chars default)mask(value, length) for custom-length hex tokenmask(value, 'base64') / mask(value, 'base32') for alternative encodingsmask(value, 'asterisk') for **************** (length configurable)mask(value, 'redact') for fixed [REDACTED] outputmask(value, 'email') to preserve @domain and mask local part: mask(arg('email'), 'email', 4) -> ****@example.comarg(index) to reference a previously evaluated arg by zero-based indexarg('name') to reference by name when using named args (map-style args:)cond(predicate, trueVal, falseVal) for conditional valuesnullable(expr, probability) for nullable columnsbool() + arg() + cond() for mutually exclusive columnsargs:
email: gen('email')
region: ref_same('regions').name
amount: uniform(1, 500)
label: arg('email') + " (" + arg('region') + ")"
$1, $2, etc. in declaration orderarg(0) still works with named argsobjects section. Each object is a map of field names to expressions:
objects:
order:
email: gen('email')
product: gen('productname')
quantity: int(uniform(1, 100))
ordered_at: timestamp('2024-01-01T00:00:00Z', '2025-01-01T00:00:00Z')
object: object_name expands all fields as positional args in declaration order:
- name: insert_order
type: exec
object: order
query: INSERT INTO "order" (email, product, quantity, ordered_at) VALUES ($1, $2, $3, $4)
field('name') cherry-picks fields when object: is set (mixable with other expressions):
object: order
args:
- field('email')
- field('product')
obj('name', 'field') accesses a specific field without object::
args:
- obj('order', 'email')
- obj('order', 'product')
obj('name').field evaluates all fields, accesses via dot notation (cached per query execution):
args:
- obj('order').email
- obj('order').product
object: works with exec_batch + __values__ for bulk inserts using an object templateprint field evaluates expressions each iteration and displays aggregated values:
print:
# Simple form (auto-aggregated: frequency for strings, min/avg/max for numbers)
- ref_same('regions').name
- arg('amount')
# Custom aggregation with expr + agg
- expr: arg('amount')
agg: "'avg $' + string(int(avg)) + ' n=' + string(count)"
ref_same, ref_rand, arg(), global(), local()agg expressions can use: count, freq, min, max, avg, sumrun section queriespost_print field works like print but evaluates after query execution, giving access to result():
post_print:
- expr: result().total
agg: "string(int(min)) + '..' + string(int(max))"
result() returns the first row of a type: query SELECT result as a map (e.g. result().column_name)post_print when you need to observe query output (balances, counts, totals) in progress outputexec_batch with count (total rows) and size (rows per batch)gen_batch(total, batchSize, pattern) for generating batched valuesbatch(n) for sequential indicesiter() for a 1-based row counter within batch queries (resets per query)uniq("expression") to retry a generator until a unique value is produced (e.g., uniq("gen('airlineairportiata')") for unique IATA codes). Defaults to 100 retries; override with uniq("expression", 500)uniq("gen('first_name')", "gen('last_name')")[0] and ...[1]. Returns []any; same-row calls with identical expressions return cached tuple__values__ token (recommended): Use __values__ in the query to generate a multi-row VALUES clause instead of driver-specific batch expansion (unnest/JSON_TABLE/OPENJSON). Produces VALUES (v1, v2), (v3, v4), ... - one INSERT per batch. Works with exec_batch/query_batch and also with type: exec/query when using batch-expanding args (gen_batch(), batch(), ref_each()). Works with pgx, mysql, mssql, spanner, dsql. For Oracle, use __values__(table(col1, col2)) to generate INSERT ALL INTO table (cols) VALUES (...) ... SELECT 1 FROM DUAL. Does not work with MongoDB or Cassandra. Also supports upsert (ON CONFLICT/ON DUPLICATE KEY/MERGE) and update via CTErun queries into an explicit BEGIN/COMMIT block using the transaction keylocals to define transaction-scoped variables evaluated once at transaction start, accessible via local('name'):
run:
- transaction: make_transfer
locals:
amount: gen('number:1,100')
queries:
- name: read_source
type: query
args: [ref_diff('fetch_accounts').id]
query: SELECT id, balance FROM account WHERE id = $1
- name: debit_source
type: exec
args: [ref_same('read_source').id, local('amount')]
query: UPDATE accounts SET balance = balance - $2 WHERE id = $1
- name: credit_target
type: exec
args: [ref_same('fetch_accounts').id, local('amount')]
query: UPDATE accounts SET balance = balance + $2 WHERE id = $1
rollback_if elements between queries for conditional early rollback:
- rollback_if: "ref_same('read_source').balance < local('amount')"
rollback_if must evaluate to a boolean and must not have name, type, args, or query fieldsrollback_if elements can be placed at different points in the transactionexec_batch, query_batch) cannot be used inside a transactionprepared: true cannot be used inside a transactionrun_weights to weight transactions against standalone queries (reference by transaction name)if/then/else for binary branching based on a boolean expression:
# Inside a transaction
- if: "ref_same('read_buyer').market == 'uk'"
then:
- name: insert_uk_order
type: exec
args: [ref_same('read_buyer').id, ref_same('read_product').price * 0.20]
query: |-
INSERT INTO order_log (customer_id, tax, currency)
VALUES ($1::UUID, $2::FLOAT, 'GBP')
else:
- name: insert_other_order
type: exec
args: [ref_same('read_buyer').id, ref_same('read_product').price * 0.10]
query: |-
INSERT INTO order_log (customer_id, tax, currency)
VALUES ($1::UUID, $2::FLOAT, 'USD')
match/when/default for multi-way dispatch:
- match: "ref_same('read_buyer').market"
when:
- eq: "'uk'"
queries:
- name: insert_uk_order
type: exec
args: [ref_same('read_buyer').id, ref_same('read_product').price * 0.20]
query: |-
INSERT INTO order_log (customer_id, tax, currency)
VALUES ($1::UUID, $2::FLOAT, 'GBP')
- eq: "'us'"
queries:
- name: insert_us_order
type: exec
args: [ref_same('read_buyer').id, ref_same('read_product').price * 0.10]
query: |-
INSERT INTO order_log (customer_id, tax, currency)
VALUES ($1::UUID, $2::FLOAT, 'USD')
default:
- name: insert_eu_order
type: exec
args: [ref_same('read_buyer').id, ref_same('read_product').price * 0.23]
query: |-
INSERT INTO order_log (customer_id, tax, currency)
VALUES ($1::UUID, $2::FLOAT, 'EUR')
let (DSL) or locals: (YAML) inside conditional branches to set variables that persist for the rest of the transaction (or run iteration for standalone). Reduces repetition when branches differ only in a few values:
# YAML: each branch sets locals, one query references them via local()
- if: "ref_same('read_buyer').market == 'uk'"
then:
- locals:
tax_rate: "0.20"
- locals:
currency: "'GBP'"
else:
- locals:
tax_rate: "0.10"
- locals:
currency: "'USD'"
- name: insert_order
type: exec
args:
- ref_same('read_buyer').id
- ref_same('read_product').price * local('tax_rate')
- local('currency')
query: |-
INSERT INTO order_log (customer_id, tax, currency)
VALUES ($1::UUID, $2::FLOAT, $3::STRING)
// DSL equivalent
if ref_same('read_buyer').market == 'uk' {
let tax_rate = 0.20
let currency = 'GBP'
} else {
let tax_rate = 0.10
let currency = 'USD'
}
insert_order `INSERT INTO order_log ...` (local('tax_rate'), local('currency'))
if/then/else and match/when/defaultlocals: set but no name, type, or queryelse and default are optional- noop (do nothing, works anywhere) and - rollback (roll back transaction, only inside transactions)if condition must evaluate to boolean; match and eq values are compared as stringsname, type, args, or query fieldsrun_weightsexamples/conditional_if/, examples/conditional_match/workers section for background maintenance queries that run on a fixed schedule alongside the main workloadrate field (recurring) or a delay field (one-shot)times/interval (e.g. 1/10s = once every 10 seconds, 3/1m = 3 times per minute)3/1m fires every 20 secondsdelay instead of rate executes once after the specified duration, then stops. Useful for mid-run schema changes or one-shot maintenancerate or delay, not bothname, type, args, prepared, object, ignore, request_timeout, etc.ignore: true)workers:
- name: reap_expired_leases
rate: 1/5s
type: exec
query: |-
UPDATE runs
SET status = 'pending', worker_id = NULL
WHERE status = 'claimed' AND lease_expires_at < now()
- name: refresh_counts
rate: 3/1m
type: query
query: SELECT count(*) AS total FROM events
- name: add_index
delay: 30s
type: exec
query: CREATE INDEX IF NOT EXISTS idx_status ON orders (status)
hooks section for event-driven listeners that run alongside the main workloadkafka (consumer) and http (endpoint)Body parsing:
parse_body: json auto-parses the JSON body into a map. Parsed fields are accessible via hook('field_name') in query args__meta__ is available in query arg expressions for transport metadata:
key, topic, partition, offset, headers (map)method, path, headers (map)Key function:
hook('name') - access a parsed body field by name in query argsYAML example (Kafka):
hooks:
orders:
type: kafka
brokers: ["127.0.0.1:9092"]
topic: orders
group: edg-orders
parse_body: json
queries:
- name: insert_order
type: exec
args: [hook('order_id'), hook('amount')]
query: "INSERT INTO orders (id, amount) VALUES ($1, $2)"
YAML example (HTTP):
hooks:
payments:
type: http
addr: "0.0.0.0:3030"
method: POST
path: /payments
parse_body: json
queries:
- name: insert_payment
type: exec
args: [hook('payment_id'), hook('amount')]
query: "INSERT INTO payments (id, amount) VALUES ($1, $2)"
DSL example:
hooks {
orders(type: kafka, brokers: "127.0.0.1:9092", topic: "orders", group: "edg-orders", parse_body: "json") {
insert_order `INSERT INTO orders (id, amount) VALUES ($1, $2)`
(hook('order_id'), hook('amount'))
}
payments(type: http, addr: "0.0.0.0:3030", method: "POST", path: "/payments", parse_body: "json") {
insert_payment `INSERT INTO payments (id, amount) VALUES ($1, $2)`
(hook('payment_id'), hook('amount'))
}
}
Validation requirements:
type must be kafka or httpparse_body is required (currently only json is supported)brokers, topic, and groupaddr, method, and pathaddr valuesstages section to define sequential workload phases with different worker counts and durationsname, workers, duration, and an optional run_weights overriderun_weights, workers in that stage use those weights instead of the top-level run_weightsrun_weights, it falls back to the top-level run_weightsrun_weights, all run items execute sequentiallystages is defined, the -w and -d CLI flags are ignored
stages:
- name: ramp
workers: 1
duration: 10s
run_weights:
check_balance: 90
credit_account: 5
make_transfer: 5
- name: steady
workers: 10
duration: 30s
# Falls back to top-level run_weights
- name: cooldown
workers: 2
duration: 10s
run_weights:
check_balance: 50
credit_account: 5
make_transfer: 45
global_iter() with math functions and globals to make generated data change shape over a workload's lifetimetotal_iterations = workers * duration_seconds / avg_latency_secondstotal_iters (or similar) as a global so expressions can normalize global_iter() to a 0–1 progress ratiozipf(initial_skew + (final_skew - initial_skew) * global_iter() / total_iters, 1, max)floor(base * (1.0 + log(1.0 + global_iter() / 1000.0)) * 100.0) / 100.0floor(abs(base + 0.5 * sqrt(global_iter()) + amplitude * sin(2.0 * pi * global_iter() / period)))pow(cos(pi * mod(global_iter(), interval) / interval), 2.0) * scalebase + (2.0 * atan(sqrt(global_iter()) / 100.0) / pi) * max_drift + noiseperiod = total_iterations / 2 for 2 visible cycles)ignore: true on a query, transaction, or worker hides it from progress output, summary table, Prometheus metrics, and expectationsrun:
- name: refresh_cache
ignore: true
type: query
query: SELECT id, name FROM product
request_timeout on a query applies a per-execution timeout--request-timeout CLI flag
run:
- name: fast_lookup
request_timeout: 500ms
args: [ref_rand('fetch_users').id]
query: SELECT * FROM users WHERE id = $1
|- for multi-line SQL strings>- for single-line SQL that wraps for readabilitycreate_users, seed_orders, fetch_user_by_id)deseed { truncate_users \TRUNCATE TABLE users CASCADE` }`# for commentsglobals:
users: 10000
orders: 50000
batch_size: 1000
up:
- name: create_users
query: |-
CREATE TABLE IF NOT EXISTS users (
id UUID PRIMARY KEY,
email VARCHAR(255) NOT NULL,
name VARCHAR(255) NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT now()
)
- name: create_orders
query: |-
CREATE TABLE IF NOT EXISTS orders (
id UUID PRIMARY KEY,
user_id UUID NOT NULL REFERENCES users(id),
total DECIMAL(10,2) NOT NULL,
status VARCHAR(20) NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT now()
)
seed:
- name: seed_users
type: exec_batch
count: users
size: batch_size
args:
- uuid_v7()
- gen('email')
- gen('firstname') + ' ' + gen('lastname')
query: |-
INSERT INTO users (id, email, name)
__values__
- name: seed_orders
type: exec_batch
count: orders
size: batch_size
args:
- uuid_v7()
- ref_rand('fetch_users').id
- uniform_f(5.00, 500.00, 2)
- set_rand(['pending', 'shipped', 'delivered', 'cancelled'], [40, 30, 25, 5])
query: |-
INSERT INTO orders (id, user_id, total, status)
__values__
init:
- name: fetch_users
type: query
query: SELECT id, email FROM users
run:
- name: get_user_orders
type: query
args:
- ref_rand('fetch_users').id
query: |-
SELECT id, total, status, created_at
FROM orders
WHERE user_id = $1
ORDER BY created_at DESC
LIMIT 10
- name: place_order
type: exec
args:
- uuid_v7()
- ref_rand('fetch_users').id
- uniform_f(5.00, 500.00, 2)
query: |-
INSERT INTO orders (id, user_id, total, status)
VALUES ($1, $2, $3, 'pending')
run_weights:
get_user_orders: 70
place_order: 30
deseed:
- name: truncate_orders
type: exec
query: TRUNCATE TABLE orders
- name: truncate_users
type: exec
query: TRUNCATE TABLE users
down:
- name: drop_orders
type: exec
query: DROP TABLE IF EXISTS orders
- name: drop_users
type: exec
query: DROP TABLE IF EXISTS users
The same workload in DSL format (~60% smaller):
let users = 10000
let orders = 50000
let batch_size = 1000
up {
create_users `CREATE TABLE IF NOT EXISTS users (
id UUID PRIMARY KEY,
email VARCHAR(255) NOT NULL,
name VARCHAR(255) NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT now()
)`
create_orders `CREATE TABLE IF NOT EXISTS orders (
id UUID PRIMARY KEY,
user_id UUID NOT NULL REFERENCES users(id),
total DECIMAL(10,2) NOT NULL,
status VARCHAR(20) NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT now()
)`
}
seed {
seed_users(count: users, size: batch_size)
`INSERT INTO users (id, email, name) __values__`
(uuid_v7(), gen('email'), gen('firstname') + ' ' + gen('lastname'))
seed_orders(count: orders, size: batch_size)
`INSERT INTO orders (id, user_id, total, status) __values__`
(
uuid_v7(),
ref_rand('fetch_users').id,
uniform_f(5.00, 500.00, 2),
set_rand(['pending', 'shipped', 'delivered', 'cancelled'], [40, 30, 25, 5])
)
}
init {
fetch_users `SELECT id, email FROM users`
}
run {
get_user_orders
`SELECT id, total, status, created_at
FROM orders
WHERE user_id = $1
ORDER BY created_at DESC
LIMIT 10`
(ref_rand('fetch_users').id)
place_order
`INSERT INTO orders (id, user_id, total, status)
VALUES ($1, $2, $3, 'pending')`
(uuid_v7(), ref_rand('fetch_users').id, uniform_f(5.00, 500.00, 2))
}
weights {
get_user_orders = 70
place_order = 30
}
deseed {
truncate_orders `TRUNCATE TABLE orders`
truncate_users `TRUNCATE TABLE users`
}
down {
drop_orders `DROP TABLE IF EXISTS orders`
drop_users `DROP TABLE IF EXISTS users`
}
Apply these patterns based on the target driver.
UUID type with DEFAULT gen_random_uuid()STRING (CockroachDB) or VARCHAR(n) (PostgreSQL)DEFAULT now()generate_series(1, $1) for bulk generation inside SQLARRAY[...] type and array(minN, maxN, pattern) expressionVECTOR(n) type and vector(dims, clusters, spread) expression for synthetic clustered vectors, or embed(text...) for real embeddings from an external API. embed() requires a license and --embed-api-key; dimensions must match the VECTOR(n) column type and --embed-dimensions flag. Use --embed-max-batch to limit texts per API call in batch queriesunnest(string_to_array('$1', __sep__)) to expand batch args into rows. __sep__ is a query-text token that emits the correct SQL separator function for the target driver (chr(31) for pgx, CHAR(31) for MySQL/MSSQL, codepoints-to-string(31) for Oracle, CODE_POINTS_TO_STRING([31]) for Spanner)__values__ to generate a multi-row VALUES clause. Simpler than unnest and produces one INSERT per batch:
query: |-
INSERT INTO t (name, email) __values__
__values__ with ON CONFLICT:
query: |-
INSERT INTO t (name, price) __values__
ON CONFLICT (name) DO UPDATE SET price = EXCLUDED.price
__values__:
query: |-
UPDATE t SET price = v.price
FROM (__values__) AS v(id, price)
WHERE t.id = v.id::UUID
ON CONFLICT (col) DO UPDATE SET ...LIMIT $1 OFFSET $2ORDER BY random()TRUNCATE TABLE ... CASCADECREATE TABLE IF NOT EXISTS, DROP TABLE IF EXISTSCHAR(36) with DEFAULT (UUID())VARCHAR(n) - always specify lengthDEFAULT CURRENT_TIMESTAMPWITH RECURSIVE seq AS (
SELECT 1 AS s UNION ALL SELECT s + 1 FROM seq WHERE s < $1
) SELECT * FROM seq
JSON_TABLE to convert batch args into rows. __sep__ emits the driver-aware separator:
SELECT j.val FROM JSON_TABLE(
CONCAT('["', REPLACE('$1', __sep__, '","'), '"]'),
'$[*]' COLUMNS(val VARCHAR(255) PATH '$')
) j
__values__ for simpler multi-row VALUES:
query: |-
INSERT INTO t (name, email) __values__
ON DUPLICATE KEY UPDATE col = VALUES(col)ELT(index, 'val1', 'val2', ...) instead of array indexingORDER BY RAND()DELETE FROM table (preferred over TRUNCATE for FK-safe cleanup)UNIQUEIDENTIFIER with DEFAULT NEWID()NVARCHAR(n) for Unicode support, NVARCHAR(MAX) for unlimitedDATETIME2 with DEFAULT GETDATE()OPTION (MAXRECURSION 0):
WITH seq AS (
SELECT 1 AS s UNION ALL SELECT s + 1 FROM seq WHERE s < $1
) SELECT * FROM seq OPTION (MAXRECURSION 0)
batch_format: json and OPENJSON:
SELECT value FROM OPENJSON('$1')
__values__ for simpler multi-row VALUES (max 1000 rows per INSERT):
query: |-
INSERT INTO t (name, email) __values__
MERGE INTO ... USING ... ON ... WHEN MATCHED THEN UPDATE ... WHEN NOT MATCHED THEN INSERT ...IF OBJECT_ID('table_name', 'U') IS NULL CREATE TABLE table_name (...)
OFFSET @p1 ROWS FETCH NEXT @p2 ROWS ONLYORDER BY NEWID()DELETE FROM table (preferred)NUMBER GENERATED ALWAYS AS IDENTITY for auto-increment, or explicit NUMBER type (UUID is uncommon)VARCHAR2(n) - Oracle-specific typeDEFAULT SYSTIMESTAMPCONNECT BY:
SELECT LEVEL FROM DUAL CONNECT BY LEVEL <= $1
XMLTABLE to expand batch args. __sep__ emits the driver-aware separator:
SELECT column_value FROM XMLTABLE(('"' || REPLACE('$1', __sep__, '","') || '"'))
__values__(table(col1, col2)) for Oracle INSERT ALL:
query: INSERT ALL __values__(product(name, price))
Generates: INTO product (name, price) VALUES (...)\nINTO product ... \nSELECT 1 FROM DUALMERGE INTO ... USING (SELECT :1 AS col FROM DUAL) src ON ... WHEN MATCHED THEN UPDATE ... WHEN NOT MATCHED THEN INSERT ...BEGIN
EXECUTE IMMEDIATE 'CREATE TABLE ...';
EXCEPTION WHEN OTHERS THEN
IF SQLCODE != -955 THEN RAISE; END IF;
END;
DROP TABLE ... CASCADE CONSTRAINTS PURGEDECODE(index, 1, 'val1', 2, 'val2', ...) instead of array indexingDBMS_RANDOM.VALUE() for floats, DBMS_RANDOM.STRING() for stringsFETCH FIRST :1 ROWS ONLYORDER BY DBMS_RANDOM.VALUEINT64, FLOAT64, NUMERIC, STRING(n), BOOL, TIMESTAMP, BYTES(n)STRING(36) with DEFAULT (GENERATE_UUID())STRING(n) - always specify max lengthTIMESTAMP with DEFAULT (CURRENT_TIMESTAMP())RAND(): Use MOD(ABS(FARM_FINGERPRINT(GENERATE_UUID())), N) for random integersCHR(): Use CODE_POINTS_TO_STRING([code_point]) insteadTRUNCATE: Use DELETE FROM table WHERE TRUE for deseedUNNEST(...) AS v(col1, col2): Column aliasing on UNNEST is unsupported. Use __values__ insteadDROP INDEX IF EXISTS idx before DROP TABLE IF EXISTS t in the down sectiongen('number:...') returns float64, which Spanner rejects for INT64 columns when using native bind params (@pN). Wrap in int(): int(gen('number:1,100'))template('%v', value) or inlined '$1' placeholders instead of @pN__values__ (recommended) or UNNEST(SPLIT('$1', CODE_POINTS_TO_STRING([31]))) AS valTABLESAMPLE RESERVOIR (N ROWS) or ORDER BY FARM_FINGERPRINT(GENERATE_UUID())INSERT OR UPDATE INTO t (...) VALUES (...)INSERT OR IGNORE INTO t (...) VALUES (...)LIMIT @p1 OFFSET @p2CREATE TABLE IF NOT EXISTS, DROP TABLE IF EXISTSpgx (uses PostgreSQL wire protocol)STRING type) may not be available; prefer standard PostgreSQL typesMongoDB uses BSON/JSON command syntax instead of SQL. Queries are JSON objects specifying the command and its parameters.
{"create": "name"} to create, {"drop": "name"} to drop{"insert": "collection", "documents": [{"_id": $1, "field": $2}]}{"find": "collection", "filter": {}} or {"find": "collection", "filter": {"field": $1}}{"delete": "collection", "deletes": [{"q": {}, "limit": 0}]}{"update": "collection", "updates": [{"q": {"_id": $1}, "u": {"$set": {"field": $2}}}]}$1, $2, etc. are inlined directly into the JSON command textup creates collections, down drops themexec_batch with count/size; each batch inserts one document per execution{"find": "collection", "filter": {}} in seed or init with type: query to populate datasetsobjectid() to generate MongoDB ObjectIDs. Format as {"$oid": "$1"} in JSON commandstransaction: blocks for MongoDB using multi-document sessions. Commands run within a session context and are committed or rolled back atomically. Use the same transaction: / locals / rollback_if syntax as SQL driverscount command and $count aggregation stage cannot be used inside multi-document transactions. Use $group with $cond instead - it always returns a document even when no rows match:
{"aggregate": "coll", "pipeline": [{"$group": {"_id": null, "n": {"$sum": {"$cond": [{"$eq": ["$field", true]}, 1, 0]}}}}], "cursor": {}}
--url, not dedicated CLI flags. Append ?w=majority for write concern, ?readConcernLevel=majority for read concern, and ?readPreference=secondaryPreferred for read routing. Use --retries 3 to handle transient WriteConflict errors under contentionExample:
up:
- name: create_users
type: exec
query: |-
{"create": "users"}
seed:
- name: insert_users
type: exec_batch
count: 1000
args:
- gen('uuid')
- gen('email')
query: |-
{"insert": "users", "documents": [{"_id": $1, "email": $2}]}
- name: fetch_users
query: |-
{"find": "users", "filter": {}}
init:
- name: load_users
query: |-
{"find": "users", "filter": {}}
run:
- name: get_user
args:
- ref_rand('load_users')._id
query: |-
{"find": "users", "filter": {"_id": $1}}
deseed:
- name: delete_users
type: exec
query: |-
{"delete": "users", "deletes": [{"q": {}, "limit": 0}]}
down:
- name: drop_users
type: exec
query: |-
{"drop": "users"}
Cassandra uses CQL (Cassandra Query Language). Tables must live inside a keyspace.
CREATE KEYSPACE IF NOT EXISTS ks WITH replication = {'class': 'SimpleStrategy', 'replication_factor': 1}CREATE TABLE IF NOT EXISTS ks.table (id UUID PRIMARY KEY, ...)UUID, TEXT, INT, DOUBLE, TIMESTAMP, BOOLEAN, BLOBDEFAULT values: Generate all values in args (e.g., gen('uuid') for UUIDs)INSERT INTO ks.table (col1, col2) VALUES ($1, $2)SELECT col1, col2 FROM ks.table or SELECT ... WHERE partition_key = $1exec_batch with count/size; edg uses Cassandra's unlogged batch internallyTRUNCATE ks.table for deseedDROP TABLE IF EXISTS ks.table then DROP KEYSPACE IF EXISTS ks$1, $2, etc.; edg converts to ? automaticallytransaction: blocks for Cassandra using logged batches. Reads execute immediately; writes are buffered and committed atomically. Use the same transaction: / locals / rollback_if syntax as SQL driverscassandra://user:pass@host1,host2,host3:9042/keyspace. Port and auth apply to all hosts--cassandra-default-consistency to set read/write consistency (one, quorum, all, local_quorum, etc.). Default is quorum--cassandra-serial-consistency for lightweight transactions (LWT). Values: serial (global Paxos) or local_serial (local DC only, default)--cassandra-idempotent to enable speculative execution and retry on other nodes. Safe for reads and repeatable writes--cassandra-no-discovery to skip system.peers lookup and connect only to seed hosts. Useful behind load balancers or for faster startup--no-atomic-tx since Cassandra does not support IF conditions inside batchesExample:
up:
- name: create_keyspace
type: exec
query: |-
CREATE KEYSPACE IF NOT EXISTS edg
WITH replication = {'class': 'SimpleStrategy', 'replication_factor': 1}
- name: create_users
type: exec
query: |-
CREATE TABLE IF NOT EXISTS edg.users (
id UUID PRIMARY KEY,
email TEXT
)
seed:
- name: insert_users
type: exec_batch
count: 1000
args:
- gen('uuid')
- gen('email')
query: |-
INSERT INTO edg.users (id, email) VALUES ($1, $2)
- name: fetch_users
query: |-
SELECT id, email FROM edg.users
init:
- name: load_users
query: |-
SELECT id FROM edg.users
run:
- name: get_user
args:
- ref_rand('load_users').id
query: |-
SELECT id, email FROM edg.users WHERE id = $1
deseed:
- name: truncate_users
type: exec
query: TRUNCATE edg.users
down:
- name: drop_users
type: exec
query: DROP TABLE IF EXISTS edg.users
- name: drop_keyspace
type: exec
query: DROP KEYSPACE IF EXISTS edg
If the user already has a database with tables, suggest edg init to generate a starting config:
# PostgreSQL / CockroachDB
edg init --driver pgx --url "postgres://..." --schema public > workload.yaml
# MySQL (use --database for the database name)
edg init --driver mysql --url "root:pass@tcp(localhost:3306)/dbname?parseTime=true" --database dbname > workload.yaml
# MSSQL
edg init --driver mssql --url "sqlserver://..." --schema dbo > workload.yaml
# Oracle
edg init --driver oracle --url "oracle://..." --schema SYSTEM > workload.yaml
# Aurora DSQL
edg init --driver dsql --url "clusterid.dsql.us-east-1.on.aws" --schema public > workload.yaml
The --schema and --database flags are interchangeable. Use --schema for drivers where the value is a schema name (pgx, dsql, mssql, oracle) and --database for drivers where it's a database name (mysql).
The generated config is a starting point, seed expressions will match column types and constraints but won't produce realistic data. The user should refine the config after generation.
If the user wants to generate a workload config that approximates a real production workload, suggest edg capture. It reads query statistics from built-in stats views (pg_stat_statements for PostgreSQL, crdb_internal.statement_statistics for CockroachDB) and produces a config with run, run_weights, and stages sections. Requires a pro license.
# CockroachDB
edg capture \
--driver pgx \
--flavour cockroachdb \
--url "postgres://root@localhost:26257/movr?sslmode=disable" \
--name workload
# PostgreSQL (requires pg_stat_statements extension)
edg capture \
--driver pgx \
--flavour postgres \
--url "postgres://user:pass@localhost:5432/mydb?sslmode=disable" \
--name workload
The --name flag is required and specifies the output file name without extension. Capture always writes both <name>.yaml and <name>.edg.
| Flag | Required | Default | Description |
|---|---|---|---|
--flavour | Yes | Database flavour: postgres or cockroachdb | |
--name | Yes | Output file name without extension (writes both .yaml and .edg) | |
--min-calls | No | 10 | Minimum call count to include a query |
--top | No | 50 | Maximum number of queries to include |
--duration | No | 1h | Assumed observation window for think time calculation |
--workers | No | 10 | Number of workers in the generated config |
--schema | No | Schema or database name to introspect for up/down DDL | |
--database | No | Alias for --schema |
When --schema or --database is provided, capture also inspects the target schema and adds up (CREATE TABLE) and down (DROP TABLE IF EXISTS) sections to the output. This uses the same introspection logic as edg init, reading the database's own DDL and sorting tables by foreign key dependencies. This produces a self-contained config that can create the schema, run the workload, and tear it down.
edg capture \
--driver pgx \
--flavour cockroachdb \
--url "postgres://root@localhost:26257/movr?sslmode=disable" \
--schema public \
--name workload
Without --schema, the output contains only stages, run_weights, and run - suitable for replaying against an existing database.
TODO placeholders. The user must replace these with appropriate expressions (e.g., ref_rand('fetch_users').id).run_weights are proportional to observed call counts.wait durations are derived from inter-arrival times.stages entry is generated matching the observation window and worker count.Do NOT generate configs with these errors:
| Mistake | Why it breaks | Fix |
|---|---|---|
Missing type: query on init queries | Defaults to exec, won't populate the named dataset | Add type: query to every init entry |
ref_rand('x') before dataset x is populated | No init or seed query with type: query named x exists | Add an init query with name: x and type: query |
exec_batch/query_batch inside transaction: | Not supported - batch types cannot be used in transactions | Use exec/query inside transactions |
prepared: true inside transaction: | Not supported | Remove prepared: true from transaction queries |
gen('number:1,100') for Spanner INT64 columns | Returns float64, Spanner rejects for INT64 bind params | Wrap in int(): int(gen('number:1,100')) |
| Mixing named and positional args in one query | Mutually exclusive - use map-style OR list-style, not both | Pick one form per query |
seq_global("name") without seq: section | Sequence doesn't exist at runtime | Add matching seq: config entry |
count/size on non-batch query types | Only valid for exec_batch/query_batch | Use type: exec_batch or remove count/size |
__values__ with MongoDB or Cassandra | Only works with SQL drivers (pgx, mysql, mssql, oracle, spanner, dsql) | Use single-document inserts for MongoDB, standard CQL for Cassandra |
run_weights referencing nonexistent run item | Key must match a name in run section | Fix name or add matching run item |
Run items without name when run_weights is set | All run items need names for weight matching | Add name to every run item |
- rollback outside a transaction | rollback only valid inside transactions | Use - noop or remove the entry |
Standalone if/match with run_weights | Conditional blocks can't be weighted | Remove conditionals from weighted run or remove run_weights |
| Using DSL for stages/conditionals/seq/print/complete | These features are YAML-only | Switch to .yaml format |
Using query: |- syntax in .edg files | DSL uses backticks for SQL, not YAML block scalars | Use `SQL here` |
| Missing backticks around SQL in DSL | Parser expects backtick-delimited SQL | Wrap SQL in backticks |
Using name: / type: YAML keys in DSL | DSL infers type from SQL verb; name is a bare identifier | Use DSL syntax: query_name \SQL`` |
DSL args (...) on a new line after SQL backtick | Parser expects identifier, sees ( - treats args as a new (invalid) query | Put opening ( on the same line as the closing backtick: \`SQL\` ( then args can wrap to next lines |
The edg stage command generates data to files instead of executing against a database. No --url or database connection is required. This is useful for previewing generated data, loading into external tools, or generating migration scripts.
edg stage --config <path> --format <format> --output-dir <dir>
| Flag | Short | Default | Description |
|---|---|---|---|
--format | -f | sql | Output format: sql, json, csv, parquet, or stdout |
--output-dir | -o | . | Directory for output files (created if it doesn't exist) |
| Format | File naming | Description |
|---|---|---|
sql | {section}.sql | Executable SQL statements (DDL + one resolved statement per generated row) |
json | {section}.json | Objects keyed by query name (data-generating queries only) |
csv | {section}_{query}.csv | CSV with headers per data-generating query |
parquet | {section}_{query}.parquet | Apache Parquet per data-generating query (all columns as optional byte arrays) |
stdout | (none) | Streams resolved SQL to stdout (no files written, log output suppressed) |
exec_batch) are expanded into individual rows. The batch CSV-joining logic is bypassedref_rand, ref_each, seq_rand, etc.--driver flag still controls SQL value formatting (quote style, hex literals)--rng-seed flag produces deterministic, reproducible outputcol_1, col_2, etc.When the user wants to test dual-write consistency, CDC pipelines, or cross-database replication, generate paired configs - one per database driver - for use with edg sync run. Note: edg sync commands (run, down, verify) require a license.
INT PRIMARY KEY with seq(1, 1) instead of auto-generated IDs (SERIAL, AUTO_INCREMENT, UUID). Both databases must produce identical IDs.DEFAULT NOW() vs DEFAULT CURRENT_TIMESTAMP).args expressions (same gen(), ref_rand(), uniform(), set_rand(), etc.). The --rng-seed flag + PRNG re-seeding ensures identical values. Fetched datasets (ref_rand) are sorted deterministically before use, so different database row orderings won't cause divergence.exec_batch: Sync configs should use type: exec_batch with count and size for efficient bulk inserts.__values__ for a cross-driver multi-row VALUES clause (works with pgx, mysql, mssql, spanner, dsql, and oracle via __values__(table(cols))). Also works with type: exec + gen_batch()/batch()/ref_each().uniform_f(min, max, precision) to generate floats with fixed decimal places. This avoids false mismatches from floating-point representation differences (e.g. 364.8 vs 364.80).run section: Sync configs only need up, seed, deseed, and down. The benchmark workload is separate.globals values (row counts, batch sizes).size: 50 or similar in the config to keep batches small.For a CockroachDB + MySQL sync pair, generate two files:
crdb.yaml:
globals:
user_count: 1000
batch_size: 100
up:
- name: create_users
query: |-
CREATE TABLE IF NOT EXISTS users (
id INT PRIMARY KEY,
email VARCHAR(255) NOT NULL,
name VARCHAR(255) NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT NOW()
)
seed:
- name: seed_users
type: exec_batch
count: user_count
size: batch_size
args:
- seq(1, 1)
- gen('email')
- gen('name')
query: |-
INSERT INTO users (id, email, name)
__values__
mysql.yaml:
globals:
user_count: 1000
batch_size: 100
up:
- name: create_users
query: |-
CREATE TABLE IF NOT EXISTS users (
id INT PRIMARY KEY,
email VARCHAR(255) NOT NULL,
name VARCHAR(255) NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
)
seed:
- name: seed_users
type: exec_batch
count: user_count
size: batch_size
args:
- seq(1, 1)
- gen('email')
- gen('name')
query: |-
INSERT INTO users (id, email, name)
__values__
After generating paired configs, show the user how to run them:
edg sync run \
--source-driver pgx --source-url "postgres://..." --source-config crdb.yaml \
--target-driver mysql --target-url "root:pass@tcp(...)/?parseTime=true" --target-config mysql.yaml \
--rng-seed 42
edg sync verify \
--source-driver pgx --source-url "postgres://..." \
--target-driver mysql --target-url "root:pass@tcp(...)/?parseTime=true" \
--tables users --order-by id --ignore-columns created_at
Add --verbose to sync verify to print individual row-level mismatches. Without it, only the per-table summary is shown.
sync verify supports all drivers including MongoDB and Cassandra. For Cassandra, use 127.0.0.1 instead of localhost in the URL to avoid IPv6 connection warnings.
For CDC mode (source only, replication handles target), omit --target-config from sync run.