synth

package module
v1.7.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Aug 12, 2026 License: MIT Imports: 29 Imported by: 0

README

Synth

Synth

Demo — bakhod1r.github.io/synth — the workbench, generator compiled to WebAssembly, nothing uploaded.

Fakers give you random strings. Synth gives you a dataset that holds together.

A user's email matches their name. A transaction points at a real account, in that account's currency, with a timestamp after the account was opened. Every card passes Luhn, every IBAN passes its checksum. That's the difference: fakers generate fields, Synth generates records that reference each other — at millions of rows per run, streamed, in constant memory.

Synth generates realistic, locale-aware data — users, payments, transactions, business records — instead of random fake values. It streams millions of records to CSV, JSONL, SQL INSERT files, or Parquet with minimal memory usage, and can produce valid request payloads from OpenAPI schemas.

How it differs from a faker

Faker libraries Synth
Scope one field at a time whole records, with relations between them
Consistency Name() and Email() are unrelated email derives from the name, city matches the postcode
Validity random digits Luhn-valid cards, checksum-valid IBANs, real BIN ranges
Volume build a slice in memory streamed, constant memory at 100M+ rows
Output strings you wire up yourself CSV, JSONL, SQL, Parquet, CDC files
Schemas none generates valid payloads from your OpenAPI spec

How it fits together

Every input format becomes the same intermediate schema, so a feature written once — coherence, constraints, masking — works no matter where the schema came from. Adding a frontend costs one parser and nothing else.

flowchart LR
  subgraph front["Frontends"]
    direction TB
    A1["Go structs"]
    A2["YAML spec"]
    A3["OpenAPI 3"]
    A4["SQL DDL"]
    A5["JSON Schema / Avro"]
    A6["Protobuf"]
    A7["Real CSV/JSONL<br/>(profile)"]
  end

  IR["schema.Schema<br/><i>one intermediate form</i>"]

  subgraph engine["Engine"]
    direction TB
    E1["providers<br/>264 types"]
    E2["locale<br/>52 locales"]
    E3["constraints<br/>+ coherence"]
    E4["per-instance PCG rng"]
  end

  subgraph out["Output"]
    direction TB
    O1["CSV / JSONL / SQL"]
    O2["Parquet"]
    O3["CDC events"]
    O4["in memory"]
  end

  A1 --> IR
  A2 --> IR
  A3 --> IR
  A4 --> IR
  A5 --> IR
  A6 --> IR
  A7 --> IR
  IR --> engine
  engine --> out

Features

Referential integrity

Records are generated as a graph, not a list. Declare a relation once and every child row points at a parent that actually exists.

users := synth.Users(10_000)
synth.Orders(500_000, synth.BelongsTo(users, "user_id"))

Foreign keys resolve. Cardinality is controllable (OneToMany, Weighted). Load the exported parent table into Postgres with your own loader and the child table's FK constraints pass on the first try.

Keys resolve across runs too, not only within one process. Generate the parent today, the child next week, and point the child at the keys already on disk:

synth gen -s users.yaml  -o users.csv  -n 10000
synth gen -s orders.yaml -o orders.csv -n 500000 --fk user_id=users.csv:id

And --append extends a dataset without regenerating it — a sidecar tracks how much exists so the new rows never repeat the old ones or their primary keys:

synth gen -s users.yaml -o users.csv -n 1000000 --append   # a million more, no collisions
Temporal causality

Timestamps aren't random points in a range — they respect the order events can happen in. An order is created → paid → shipped → delivered, each strictly after the last, with realistic gaps. Accounts are never used before they're opened, and refunds never precede their charge.

synth.Orders(1_000, synth.Timeline("2026-01-01", "2026-07-01"), synth.Lifecycle(synth.OrderFlow))
Unique columns

A column marked unique gets distinct values. By default Synth resamples until it finds a fresh one, tracking everything generated so far — values stay natural, but memory grows with the row count, and a column with fewer possible values than rows is an error rather than a silent duplicate:

synth: field "status" ran out of unique values after 3 rows; its value space is
too small for the row count (use unique=counter, or widen the field)

unique=counter instead derives distinctness from the record index. It costs a visible suffix (ivanov.dilnoza41293@gmail.com) and buys constant memory, any row count, and parallel generation — MakeParallel accepts it, where tracked unique fields must go through Make.

type Row struct {
	ID    uuid.UUID `synth:"pk"`                    // unique by construction
	Email string    `synth:"email,unique"`          // tracked, natural values
	Slug  string    `synth:"username,unique=counter"` // constant memory at 1B rows
}
fields:
  slug: { kind: username, unique: true, unique_mode: counter }
Format-valid values

Every generated identifier passes the check a real system would run on it:

  • Credit cards — Luhn-valid, issued from real BIN ranges per brand
  • IBANs — mod-97 checksum, correct per-country length and BBAN layout
  • National IDs, VAT numbers, tax IDs — country-specific check digits
  • Emails — RFC 5322 conformant in every locale, including the ones whose names are not written in Latin: a mailbox is ASCII unless the whole mail path speaks SMTPUTF8, so names are transliterated the way their owners transliterate them
  • URLs, phone numbers — RFC / E.164 conformant
Catalogue-backed types

The built-in phone has E.164 shape; the built-in device picks from a short hand-written list. Where that is not enough, these types answer from published reference data instead — Google's libphonenumber ranges, and a catalogue of 25,000 Android handsets. No import, no registration:

type Session struct {
    Phone    string `synth:"phone_e164"`
    Display  string `synth:"phone_national,from=Phone"`
    LineType string `synth:"phone_type,from=Phone"`

    Code  string `synth:"device_code"`
    Brand string `synth:"device_brand,from=Code"`
    Model string `synth:"device_name,from=Code"`
}
// +998912341024  91 234 10 24  mobile   SM-T500  Samsung  Galaxy Tab A7
  • Phones — valid under the region's numbering plan, carrying the area code of the record's own city where the plan allows it: a Houston row gets +1 713, an Andijon row +998 94, a Roma row +39 06. Types: phone_e164, phone_national, phone_international, phone_type.
  • Devices — model codes as they appear in a User-Agent, with the brand and handset name they actually belong to. Types: device_code, device_brand, device_name.
  • Mail domains — the provider that owns an address, its canonical form, and addresses at the 8,000-odd throwaway services. Types: email_provider, email_normalized, email_disposable.

In both, one field draws and the others read it through from=, so the record describes one phone and one handset rather than several.

A number valid under a numbering plan may well be somebody's. Generated numbers are for fixtures and demos — never dial or message them.

Images that belong to their row

An avatar column that points at picsum.photos is a placeholder twice over: it needs the network, and it depicts nobody. Synth draws the picture instead, from the row's own text.

type User struct {
    ID     uuid.UUID `synth:"pk"`
    Name   string
    Avatar string `synth:"avatar,from=Name"`      // initials, colour, shape from the name
    Icon   string `synth:"identicon,from=ID"`     // the GitHub-style pixel mark
}

Four kinds: avatar (a person), productimage (a catalogue thumbnail), logo (a company monogram), identicon (a symmetric mark for any key).

The image is a pure function of the subject, so the same person keeps the same face across runs, across formats and across datasets — regenerate the fixture and the diff is empty. from= is what ties it to the row; without it the picture depicts an unrelated name.

Param Meaning
from=<field> take the subject from a sibling column
format= dataurl (default, base64 SVG), svg, png
size= edge length in pixels (default 128, max 1024)
dir= write files there, put the path in the column
seed= a different but equally stable image
vary=true let repeated subjects differ (off by default)

Nothing is fetched and no font is required: text is rasterized from a built-in 5×7 bitmap into plain rectangles, so the SVG renders identically everywhere and matches the PNG exactly. The renderer is usable on its own as imagegen, and the dataurl default drops straight into a browser with no file to ship alongside it. Worked example: examples/images, examples/catalog.yaml.

The imageurl kind is unchanged — it still returns a placeholder-service URL for cases that want one.

Locale coherence

Locale isn't just a name list. Pick uz_UZ and you get Uzbek names, +998 phone numbers, Tashkent districts, UZS amounts, and postcodes that match the city they're attached to — consistently across every field of the record.

One struct, one seed, three locales — name, phone, card and the nested address all move together (examples/localize):

Locale coherence across uz_UZ, ja_JP and de_DE

All 52 locales carry at least 1000 distinct full-name combinations per gender, drawn from real names in the language's own script. Male and female lists are kept apart, and where surnames inflect for gender the correct form is used: Novák/Nováková, Иванов/Иванова, Bērziņš/Bērziņa, Abdullayev/Abdullayeva. Tests enforce the cardinality, the script and the inflection — each is the kind of error that is invisible to a reader who does not speak the language and glaring to one who does.

Ten locales also carry their own catalog datasets — weekdays, months, seasons, weather, colours, dishes, fruit, vegetables, drinks and animals — in the local language, and with local content rather than translations: uz_UZ returns osh and somsa, pl_PL returns pierogi and żurek. Types with no dataset for the chosen locale fall back to English rather than returning nothing.

That fallback is stated, not hidden. providers.LocalesFor(kind) reports exactly which locales a type has data for, the workbench shows it on every type in the palette, and a test asserts that a type like superhero — the same word everywhere — never claims coverage it does not have.

A single column can step out of the locale with localize=false, without dragging the rest of the dataset back to English with it:

type Order struct {
    Customer string `synth:"name"`                     // Uzbek
    City     string `synth:"city"`                      // Tashkent district
    Category string `synth:"productcategory,localize=false"` // English, for the partner's system
}

The switch only bites on kinds a locale actually reaches — names, addresses, phone, currency, national IDs, and the catalog types with per-locale data. providers.Localizable(kind) answers whether a kind is one of them, and providers.LocalizableKinds() lists them all; on anything else localize= is a no-op because there was never anything locale-specific to turn off. A de-localized address field still agrees with its de-localized neighbours: they share one en_US place, so the city still matches the postcode.

A column can also name a locale of its own, which is the same lever pointed somewhere other than English — a Japanese phone number on an Uzbek customer, a German shipping city on a Turkish order:

type Order struct {
    Customer string `synth:"name"`                 // Uzbek
    Phone    string `synth:"phone,locale=ja_JP"`   // +81…
    ShipCity string `synth:"city,locale=de_DE"`    // Berlin
}
fields:
  customer: {kind: name}
  phone:    {kind: phone, locale: ja_JP}

locale= wins over localize= when both are set — naming a locale is the more specific instruction. An unknown name is a compile error rather than a silent fall back to English, because a typo that quietly changes a column's language is the failure this option exists to prevent.

Within one record the choices are not independent. A single locale.Place is drawn first, and every place-derived field reads from it — which is why the city matches the postcode instead of merely both being Uzbek.

flowchart TD
  L["locale: uz_UZ"] --> P["pick one Place<br/>region + city + postcode + phone prefix"]
  L --> G["pick a gender"]
  P --> C["city"]
  P --> R["region"]
  P --> Z["postcode"]
  P --> PH["phone"]
  G --> FN["first name"]
  G --> LN["last name<br/><i>gendered form</i>"]
  FN --> EM["email"]
  LN --> EM
  FN --> FULL["full name"]
  LN --> FULL
Statistical shape

Real data isn't uniform. Synth draws from distributions so your test data stresses the same paths production does.

Via tags:

type Txn struct {
    Amount   float64 `synth:"amount,dist=lognormal,mu=10,sigma=1"` // long tail
    Status   string  `synth:"enum,choices=settled|pending|failed,weights=0.94|0.05|0.01"`
    Category string  `synth:"enum,choices=a|b|c|d,dist=zipf,s=1.2"` // hot keys
}

Or in code:

synth.Make[Txn](1_000_000, synth.Weighted("Status", map[string]float64{
    "settled": 0.94, "pending": 0.05, "failed": 0.01,
}))

Distributions: normal, lognormal, exp (numeric fields) and zipf / explicit weights (enums).

Long tails, hot keys, and skew are what break partitioning and query planners — uniform fakers never surface those bugs.

Correlated fields and time series

Numeric columns don't have to be independent. derive makes one a linear function of another in the same row, so a scatter plot has a shape instead of a cloud:

age:    { kind: int, min: 25, max: 65 }
income: { kind: float, derive: age, slope: 1200, intercept: 20000, noise: 0.1 }

And kind: timeseries makes a column follow a curve over time — trend plus a seasonal cycle plus noise — for metrics and IoT data:

ts:  { kind: time, min: 2026-01-01T00:00:00Z, max: 2026-02-01T00:00:00Z }
cpu: { kind: timeseries, axis: ts, base: 40, trend: 0.5, amplitude: 20, period: 24h, noise: 3, min: 0, max: 100 }

Both are pure functions of the same row, so they generate in the same streaming, deterministic pass as everything else.

Constant-memory streaming

Records are pushed through a pipeline, never accumulated. Generating 100M rows uses the same memory as generating 1K. Generation is sharded across cores, and sinks batch and backpressure independently.

Deterministic and reproducible

A seed fully determines the output. The same seed produces byte-identical data across runs, machines, and Go versions — so a failing CI run is reproducible locally, and golden-file tests stay stable.

synth.Make[User](1000, synth.WithSeed(42))

Each record is seeded independently from the base seed, so parallel generation is byte-identical to serial output.

Schema-driven generation

Point Synth at a schema instead of hand-writing generators:

  • OpenAPI — valid request bodies for every endpoint, respecting format, pattern, enum, minimum, and required
  • SQL DDL — read CREATE TABLE, infer types, honor NOT NULL, UNIQUE, CHECK, and FK constraints
  • Go structs — generate from your existing domain types via tags
Test-ready outputs

CSV, JSONL, SQL INSERT files, Parquet, Postgres COPY files, and Debezium-shaped CDC events — every one of them a file. Synth never opens a database or network connection; handing the file to your loader is the last step, and it is yours.

For bulk loading, COPY is what Postgres wants — an INSERT per row is the slowest path the server offers:

synth gen -s users.yaml -n 100000000 -o users.pgbin   # binary COPY
synth gen -s users.yaml -n 100000000 -o users.pgcopy  # text COPY

Both write a matching CREATE TABLE next to the data as users.pgbin.sql. That pairing is not a convenience: binary COPY carries no type names, so the table's column types are what the server decodes the bytes as, and a table built by hand that differs in one column means a rejected file. Synth generates the DDL and the encoding from the same type table, so they cannot disagree.

psql -f users.pgbin.sql

Large outputs compress on the way out — name the file and Synth does the rest:

synth gen -s users.yaml -n 100000000 -o users.jsonl.zst
synth gen -s users.yaml -n 100000000 -o users.csv.gz
Edge-case injection

Testing the happy path is the easy part. Ask for the values that break parsers: unicode names, emoji, RTL text, empty strings, boundary numerics, nulls in nullable columns.

synth.New(synth.WithChaos(0.02))   // 2% of records carry a nasty value

Try it without installing anything

bakhod1r.github.io/synth — the workbench with the generator compiled to WebAssembly.

It is the same page and the same engine as synth ui; only the backend differs, and the page's JavaScript is byte-identical between them. Nothing is uploaded, because there is nowhere to upload it to — the generator runs in your tab. That is a stronger claim than a promise not to send your schema anywhere, and it is the whole reason the demo is built this way rather than hosted.

About 1.6 MB to download, once.

Browser workbench

synth ui            # then open http://127.0.0.1:8080

A local page with the type palette on the left, the schema in the middle, and a live preview on the right that regenerates as you type. The seed is shown and editable, because reproducibility is the thing a hosted generator cannot give you — the UI teaches it rather than hiding it.

The palette is read from the provider registry itself, so it cannot drift from what the engine actually supports, and each type is marked with whether its values really follow the locale. Most do not, and saying so beats letting you assume otherwise.

The server binds 127.0.0.1 and refuses anything elseServe("0.0.0.0:8080") returns an error, and a test enforces it. The page is embedded in the binary with inline CSS and JavaScript: no CDN, no fonts, no telemetry, no outbound request of any kind, and a test asserts the page contains no external origin. The browser connects in; Synth never connects out.

MCP

Synth speaks MCP, so an assistant can generate and check data without shelling out to the CLI:

go install github.com/bakhod1r/synth/mcp/cmd/synth-mcp@latest
claude mcp add synth -- synth-mcp

Eight tools: generate, list_types, list_presets, verify, profile, mask, snapshot, diff.

The server is stdio-only and takes every input as an argument rather than a path — it opens no socket and reads no file, and a test forbids the imports that would let it. An MCP server runs with your permissions on behalf of a model that may be reading text someone else wrote, so a path argument would turn a data generator into a file-reading primitive. See mcp/README.md.

flowchart LR
  M["Assistant"] <-->|"stdio<br/>JSON-RPC"| S["synth-mcp"]
  S --> E["Synth engine"]
  E --> S
  S -.->|"blocked by a test"| F["filesystem"]
  S -.->|"blocked by a test"| N["network"]
  S -.->|"never existed"| D["database"]

  style F stroke-dasharray: 4, color:#888
  style N stroke-dasharray: 4, color:#888
  style D stroke-dasharray: 4, color:#888

Data goes in as an argument and comes back in the response. Nothing else moves.

It is a separate module: mcp-go brings 20 transitive dependencies, kept out of the core module's graph.

Install

go get github.com/bakhod1r/synth              # library
go install github.com/bakhod1r/synth/cmd/synth@latest   # CLI
npm install @bakhod1r/synth                   # JavaScript, via WebAssembly

Quick start

Synth is a pure data provider: it never connects to a database, never runs INSERT, never reads DDL. You hand it a plain Go struct; it hands you coherent records — in memory, to a file, or streamed. Loading is a separate tool's job.

package main

import (
	"time"

	"github.com/bakhod1r/synth"
	"github.com/google/uuid"
)

type User struct {
	ID        uuid.UUID `synth:"pk"`
	FirstName string
	Email     string `synth:"email,from=FirstName"` // derived from the name
	Phone     string
	Country   string
	Region    string
	City      string
	Postcode  string // stays coherent with Country/Region/City
	Card      string `synth:"card"` // Luhn-valid HUMO/UZCARD
	CreatedAt time.Time
}

func main() {
	// Tags are optional — untagged fields are inferred from name and type.
	users := synth.Make[User](10_000, synth.WithSeed(42), synth.WithLocale("uz_UZ"))

	synth.WriteCSV("users.csv", users)                       // to a file
	synth.Stream[User](1_000_000).ToJSONL("users.jsonl")     // constant memory
}
Referential integrity
users  := synth.Make[User](10_000, synth.WithSeed(1))
orders := synth.Make[Order](500_000, synth.Ref(users, "UserID")) // every FK is real
Temporal causality

Timestamps respect the order events can happen in — a record's lifecycle stays consistent instead of scattering random points in a range.

type Order struct {
    CreatedAt   time.Time
    PaidAt      time.Time `synth:"time,after=CreatedAt,gap=1h..48h"`
    ShippedAt   time.Time `synth:"time,after=PaidAt,gap=1h..72h"`
    DeliveredAt time.Time `synth:"time,after=ShippedAt,gap=1h..120h"`
}
// CreatedAt < PaidAt < ShippedAt < DeliveredAt, always.
Fluent single values
g := synth.New(synth.Config{Seed: 42, Locale: "uz_UZ"})
g.Name()      // "Azizbek Karimov"
g.Phone()     // "+998901234567"
g.Card()      // Luhn-valid
g.Amount(1000, 500000)

Benchmarks

Measured on Apple Silicon (M-series, 8 cores), Go 1.25, go test -bench -benchmem. Each library fills the same four fields (name, email, phone, city).

Struct filling — one record from a struct definition:

Library ns/op B/op allocs/op
go-faker/faker v4 10,848 8,778 116
Synth 2,494 2,001 34
xychart-beta
  title "One record from a struct — lower is better (ns/op)"
  x-axis ["go-faker/faker v4", "Synth"]
  y-axis "nanoseconds" 0 --> 12000
  bar [10848, 2494]

Per-field calls — the fluent API:

Library ns/op B/op allocs/op
jaswdr/faker v2 5,612 4,533 61
Synth 778 222 14
xychart-beta
  title "Four fluent field calls — lower is better (ns/op)"
  x-axis ["jaswdr/faker v2", "Synth"]
  y-axis "nanoseconds" 0 --> 6000
  bar [5612, 778]

Batch generation — Synth's normal mode, where schema work is done once for the whole run: 1,678 ns/record (~596K records/sec single-threaded), and ~1.28M records/sec across 8 cores with MakeParallel.

Writing a file — 10,000 rows to disk, the same four fields. This is what the libraries are actually used for, and it is where the comparison changes shape: go-faker and jaswdr generate values and stop, so their users write the loop by hand. That loop is included below, because leaving it out would compare Synth's whole job against half of theirs.

Library Format ms/op B/op allocs/op
go-faker/faker v4 CSV (hand-written loop) 115.0 87.8 MB 1,160,247
jaswdr/faker v2 CSV (hand-written loop) 63.1 46.0 MB 620,393
Synth CSV 25.5 11.0 MB 320,051
Synth CSV, streamed 20.4 9.1 MB 300,036
jaswdr/faker v2 JSONL (hand-written loop) 96.4 46.7 MB 627,708
Synth JSONL 50.1 9.6 MB 270,060
Synth SQL INSERT 57.2 13.1 MB 400,058
xychart-beta
  title "10,000 rows to CSV — lower is better (ms)"
  x-axis ["go-faker", "jaswdr", "Synth", "Synth streamed"]
  y-axis "milliseconds" 0 --> 120
  bar [115.0, 63.1, 25.5, 20.4]

Allocation is the wider gap, and the one that decides whether a run finishes:

xychart-beta
  title "Memory to write 10,000 rows to CSV — lower is better (MB)"
  x-axis ["go-faker", "jaswdr", "Synth", "Synth streamed"]
  y-axis "megabytes allocated" 0 --> 90
  bar [87.8, 46.0, 11.0, 9.1]

The streamed row is the one that matters at scale: it never holds the rows in memory, so its footprint does not grow with the row count. At ten thousand rows the gap is small; the same code writes ten million.

Writing scales linearly — 1K/10K/100K rows take 3.2 ms, 25.2 ms and 240.9 ms:

xychart-beta
  title "Writing CSV scales linearly with row count"
  x-axis ["1K rows", "10K rows", "100K rows"]
  y-axis "milliseconds" 0 --> 260
  bar [3.2, 25.2, 240.9]
  line [3.2, 25.2, 240.9]
BenchmarkSynth_WriteCSVScaling/1000        3,204,250 ns/op     1.1 MB/op
BenchmarkSynth_WriteCSVScaling/10000      25,161,486 ns/op    11.0 MB/op
BenchmarkSynth_WriteCSVScaling/100000    240,948,389 ns/op   109.8 MB/op

A note on presets: synth gen --preset user generates about 5,000 rows/sec, which looks slow next to the numbers above. 96% of that time is one column — password_hash runs PBKDF2 at 1,000 iterations, which is a key derivation function and is meant to be expensive. The same shape without it runs at roughly 500,000 rows/sec.

Reproduce with cd benchcmp && go test -bench=. -benchmem -run=^$ . (the comparison lives in its own module, so the competing fakers never enter this library's dependency graph).

Per-instance RNG means no global-rand mutex — parallel generation scales, and same-seed output is byte-identical regardless of worker count.

Data domains

Domain Examples
People name, email, phone, address, national ID
Payments card, IBAN, merchant, currency, amount
Transactions ledger entries, timestamps, statuses
Business companies, invoices, orders, inventory

Learn from real data (profiling)

Point Synth at an export of a real table and it learns the shape — column types, numeric ranges, null rates, and the real frequency of each category — then generates synthetic rows that behave the same. Synth never connects to your database; you produce the sample yourself:

psql -c "\copy (SELECT * FROM users LIMIT 10000) TO 'sample.csv' CSV HEADER"
p, _ := synth.Profile("sample.csv")
rows, _ := p.Generate(1_000_000)   // same distribution, none of the real data

Low-cardinality columns keep their observed split (e.g. 80% active / 15% inactive / 5% banned). Identifier-like columns are never echoed back, so real values cannot leak into the output.

Learned invariants (constraint mining)

Per-column profiling learns what each column looks like. It cannot see that a total must agree with its line items, or that a refunded order must carry a refund timestamp. synth profile mines those cross-column invariants from the sample and writes them into the spec:

constraints:
  - {kind: sum, parts: [subtotal, tax], whole: total}    # held over 48,912 rows
  - {kind: ordering, left: created_at, right: updated_at}  # held over 50,000 rows
  - {kind: implication, when: status, equals: "refunded", then: refund_at, exclusive: true}

Generation then enforces them. A total is derived from its parts rather than generated independently; an out-of-order timestamp pair is swapped, which preserves both values and their distribution instead of piling duplicates up at a boundary.

Mining is conservative on purpose. A candidate is generated, then falsified against the whole sample; an implication additionally needs a trigger group of real size, and needs its target column to be empty somewhere else — otherwise the column is simply always populated and the trigger has nothing to do with it. Each surviving rule records how many rows it held over, so you can judge it rather than trust it.

If a spec's constraints contradict each other, Generate returns an error naming the unsatisfiable one. Emitting rows that quietly violate the spec would be worse than failing: the data would look authoritative and be wrong.

Audit an existing dataset (synth verify)

The rules that make Synth's output coherent are the rules worth checking on data somebody else produced. synth verify is the generator run backwards:

synth verify -i orders.csv --ref user_id=users.csv:id
synth verify -i orders.csv -s orders.yaml -f json   # also re-check mined invariants

It reports failed check digits (Luhn, IBAN mod-97, EAN-13/UPC), unparseable emails, URLs and IP addresses, dangling foreign keys, timestamp pairs in the wrong order, and columns that carry no information. Parent tables are read from their own files — nothing is queried.

Exit code 1 on any error, 0 when only warnings were found, so it drops into CI without a wrapper. A degenerate column is a warning, not an error: real data sometimes looks like that, and a tool that cries wolf gets muted.

Two rules keep it honest. A column is audited only when its values already mostly match what its name claims, so a column called card holding loyalty tiers is skipped rather than reported a million times. And a clean dataset must produce an empty report — a check that fires on correct data is a false positive and a bug here, not something for you to filter out.

Compare two datasets' shape (synth diff)

After changing a generator, or to guard a real feed against drift, synth diff answers whether two files are shaped alike — columns, types, numeric ranges, null rates, category sets — without comparing rows:

synth diff baseline.csv candidate.csv
synth diff baseline.csv candidate.csv --tolerance 0.2 -f json   # for CI

A column added, removed or retyped is an error; a range, null rate or category set that moved past tolerance is a warning. Exit code 1 on any error, 0 on warnings only — same contract as verify, so a pipeline fails on a structural break and passes on ordinary drift. The MCP server exposes the same as a diff tool over inline datasets.

Anonymize a production dump (GDPR)

Hand Synth a real export and get one that is safe to share: personal data is replaced with synthetic values of the same format, everything else is left alone.

m := synth.NewMasker("team-key", "en_US")
m.Rule(synth.MaskRule{Column: "notes", Strategy: synth.MaskRedact})
report, _ := m.File("dump.csv", "safe.csv")
  • Consistent — the same input value always maps to the same replacement, so joins and foreign keys still line up (use the same key across related dumps).
  • Format-preserving — an email stays an email, a card stays 16 digits.
  • Irreversible — replacements come from a keyed hash, not an encoding.
  • Thorough — PII is caught by column name, by value format, and inside free text (an email buried in a notes field is scrubbed too).

Two measures go further than replacement. k-anonymity checks that no combination of quasi-identifiers singles anyone out — direct identifiers gone is not enough if one person is the only 99-year-old in their ZIP:

synth verify -i safe.csv --k 5 --qi age,zip,gender   # exit 1 if any group < 5

And differential-privacy noise perturbs a numeric column with the Laplace mechanism, so a released number cannot be pinned to one record:

synth mask -i dump.csv -o safe.csv --key team-key --dp salary:1.0:10000

salary:1.0:10000 is column, epsilon (smaller = more noise), and sensitivity. The noise is reproducible under the key — input perturbation for fixtures, not query-time DP.

Change events (CDC)

Generate a coherent insert/update/delete history in Debezium's envelope shape — no database, no Kafka, just a file:

synth.WriteCDC[User]("changes.jsonl", 10_000, synth.CDCConfig{
    Table: "users", UpdateRate: 0.3, DeleteRate: 0.1, Snapshot: 100,
})

A row exists before it is updated, updates carry the true before image, and deleted rows are never touched again. LSNs and timestamps advance monotonically.

Deletes are hard by default (op=d). Pass --soft-delete (or CDCConfig.SoftDelete) to emit them as op=u updates that stamp a deleted_at column instead, so a consumer can be tested against either workload from one spec:

synth cdc -s users.yaml -n 10000 --delete-rate 0.1 --soft-delete

For a referential cascade across two tables, give a child spec and the column that references the parent. Deleting a parent then deletes its children first, then the parent — the order a foreign key requires:

synth cdc -s orders.yaml --child items.yaml --child-fk order_id --delete-rate 0.2

Inserts keep integrity (a child only ever references a parent that exists), one LSN and clock run across both tables, and the stream is deterministic under the seed.

Time travel

%%{init: {'theme':'base', 'themeVariables': {'cScaleLabel0':'#111','cScaleLabel1':'#111','cScaleLabel2':'#111','cScale0':'#4a90d9','cScale1':'#57b356','cScale2':'#9aa0a6','titleColor':'#ddd','textColor':'#ddd','lineColor':'#888'}}}%%
timeline
  title One seed, many instants
  2026-01-01 : rows born so far
  2026-04-01 : some updated : some deleted
  2026-07-01 : full state

One seed already fixes a whole dataset. Snapshots make time another axis of that determinism: ask for the table as it stood at any instant, or for what changed between two.

synth snapshot -s orders.yaml --at 2026-01-01 -o jan.csv
synth snapshot -s orders.yaml --at 2026-07-01 -o jul.csv
synth snapshot -s orders.yaml --from 2026-01-01 --to 2026-07-01 -o changes.jsonl
tl, _ := synth.Snapshot[Order](synth.SnapshotConfig{Rows: 100_000, Churn: 2, DeleteFrac: 0.1})
jan := tl.At(jan1)
jul := tl.At(jul1)
events := tl.Between(jan1, jul1)

tl.Apply(jan, events)   // == jul, exactly

That last line is the contract, and it is the point: replaying the log over the earlier snapshot reproduces the later one. Migration and incremental-ETL tests need a source of truth on both ends and the diff between them, and here all three come from one seed.

The equivalence holds by construction rather than by luck. Each row's whole life — when it was born, when it changed, whether it was deleted — is derived from its index, and At and Between read that same life. Nothing is simulated forward, so a snapshot a century out costs no more than one an hour out. Consecutive ranges tile exactly, so you can walk a timeline in steps and land on the same state as one jump.

Real-time pacing

Deliver records over wall-clock time, the way a real event source would — for streaming tests and consumer back-pressure experiments:

synth.Rate[Event](synth.RateConfig{PerSecond: 5000, Jitter: 0.2}).
    Run(ctx, func(e Event) error { return myProducer.Send(e) })

Synth paces the handoff; where the events go is your code's decision.

Input formats

Synth builds its schema from whichever definition you already have:

Source API
Go structs (tags optional) synth.Make[T]
YAML spec synth.LoadYAML
OpenAPI 3 synth.OpenAPI
SQL DDL (CREATE TABLE) synth.LoadDDL
JSON Schema synth.LoadSchema
Avro schema synth.LoadSchema
Protobuf (.proto) synth.LoadProto
Real-data sample (CSV/JSONL) synth.Profile

CLI & YAML specs

Describe data declaratively and generate it without writing Go:

# users.yaml
name: users
count: 1000
locale: uz_UZ
fields:
  id:      { kind: uuid, pk: true }
  name:    { kind: name }
  email:   { kind: email, from: name }
  status:  { kind: enum, choices: [active, inactive], weights: [0.9, 0.1] }
  balance: { kind: amount, min: 0, max: 1000000, dist: lognormal, mu: 9, sigma: 1.2 }
go install github.com/bakhod1r/synth/cmd/synth@latest
synth gen -s users.yaml -o users.csv          # or -f jsonl | sql
synth gen -s users.yaml -f sql -n 100000 --seed 42

Every library capability is reachable from the command line, and every subcommand reads and writes files — none of them connects to anything.

# Learn a spec from a real export, then generate from the spec forever after.
synth profile -i prod_export.csv -o users.yaml
synth gen -s users.yaml -n 1000000 -o fake_users.csv

# Anonymize a real dump. The same --key across files keeps foreign keys joinable.
synth mask -i prod_export.csv -o safe.csv --key "$MASK_KEY"

# Generate a coherent insert/update/delete history in Debezium's envelope shape.
synth cdc -s users.yaml -o changes.jsonl -n 10000 --update-rate 0.3 --delete-rate 0.1

synth mask refuses to run without --key (an unkeyed run is not reproducible) and refuses to write over its own input.

Output

Synth writes files — it never opens a network or DB connection.

synth.WriteCSV("users.csv", users)
synth.WriteJSONL("users.jsonl", users)
synth.WriteSQL("users.sql", "users", users) // INSERT statements you run yourself

synth.Stream[User](100_000_000).ToCSV("users.csv") // constant memory
Parquet

Parquet is a first-class output — pick it by extension or -f parquet:

synth gen -s users.yaml -n 100000 -o users.parquet
synth gen --preset user -n 50 -f parquet -o users.parquet

The same writer is available from Go:

parquet.WriteStructs("users.parquet", users)              // from Go structs
parquet.WriteRows("users.parquet", spec.Columns(), rows)  // from YAML/DDL/profiling

A Parquet file carries a footer, so it needs a real path — it does not stream to stdout or through the gzip/zstd sink, and --append does not apply to it.

Column types are inferred (int64, double, boolean, string), so query engines see real types rather than everything-as-string. Uploading the file to S3, MinIO or a warehouse is your loader's job.

Versioning and stability

Synth follows semantic versioning. The public API is frozen at v1: a breaking change means v2, and in Go v2 is a different import path — so nothing that would break your build can arrive by accident. See CHANGELOG.md.

What is already enforced rather than promised:

Tests 380+, race-clean, on every push
Fuzzing six parser targets, nightly
Core dependencies exactly two, and CI fails if that slips
Boundaries no database, no network, no files from MCP — each with a test

Status & roadmap

Implemented:

  • Frontends — Go structs with tagless inference, YAML specs and CLI (synth gen), OpenAPI-driven payloads, SQL DDL, JSON Schema, Avro, Protobuf, and real-data profiling.
  • Coherence — referential integrity (Ref), temporal causality (after=/gap= lifecycle ordering), unique constraints (unique tag, PKs), OneToMany cardinality, nested structs and slices generated recursively, and locale coherence (country → region → city → postcode → phone).
  • Validity — Luhn-valid cards (HUMO/UZCARD), mod-97 IBANs, and gender-coherent names.
  • Statistics — Normal/LogNormal/Exponential/Zipf/Weighted distributions and chaos injection (WithChaos).
  • Coverage — 264 field types across 52 locales (native names, dialing codes, currencies, capital regions), plus real-world datasets (books, movies, celebrities, brands, foods, animals, sports, universities, languages, emoji) — recognizable values blended with combinatorial ones so repetition stays low across large datasets.
  • Custom typesRegister/RegisterSet for your own values.
  • Engine — deterministic per-record RNG, parallel generation, and CSV/JSONL/SQL encoders with streaming.
synth.RegisterSet("cinema", "Inception", "Interstellar", "Tenet", "Dune")
synth.Register("rating", func(r synth.R) any { return r.IntRange(1, 5) })

Roadmap: locale datasets for the culturally-specific catalog types beyond the ten locales currently covered; protobuf map<k,v> fields. Network sinks (Kafka, Postgres) are intentionally out of scope — Synth stays a pure provider; feed its output to your own loader.

License

MIT — see LICENSE.

Documentation

Overview

Package synth generates realistic, coherent, referentially-consistent records from plain Go structs. It is a pure data provider: it never touches the network, a database, or DDL. A struct goes in; records come out — in memory, to a file, or streamed.

Index

Constants

View Source
const (
	MaskKeep   = mask.Keep
	MaskFake   = mask.Fake
	MaskRedact = mask.Redact
	MaskDrop   = mask.Drop
	MaskDP     = mask.DP
)

Masking strategies.

Variables

This section is empty.

Functions

func Fill

func Fill[T any](p *T, opts ...Option) error

Fill populates a single struct pointer in place.

func Generate

func Generate(p Preset, n int, opts ...Option) ([]map[string]any, error)

Generate produces n records from a built-in schema.

rows, _ := synth.Generate(synth.PresetTransaction, 100)

Card numbers and national identifiers come back masked. Pass Unmasked() when a test genuinely needs the raw value — for example to check that a validator accepts it.

func Make

func Make[T any](n int, opts ...Option) []T

Make generates n records of type T. It panics on configuration errors (unknown tag, dependency cycle) — use TryMake in production code.

func MakeParallel

func MakeParallel[T any](n, workers int, opts ...Option) ([]T, error)

MakeParallel generates n records across `workers` goroutines. Each worker forks its own rng from a per-record deterministic seed, so output is independent of worker count — no shared-rand mutex, and still reproducible. workers <= 0 uses GOMAXPROCS.

func Orders

func Orders(n int, opts ...Option) ([]map[string]any, error)

func ParseInstant

func ParseInstant(s string) (time.Time, error)

ParseInstant reads a date or timestamp the way the CLI accepts it.

func Payments

func Payments(n int, opts ...Option) ([]map[string]any, error)

func PresetSpec

func PresetSpec(p Preset) (string, bool)

PresetSpec returns a preset's YAML, so it can be read, edited and committed rather than treated as a black box.

func Register

func Register(name string, fn func(r R) any)

Register adds a custom field type. After this, a field can select it by tag (`synth:"cinema"`) or by a matching field name, and its value is produced by fn. fn must be deterministic given R for reproducible output.

synth.Register("cinema", func(r synth.R) any {
    return r.Pick([]string{"Inception", "Interstellar", "Tenet"})
})

func RegisterSet

func RegisterSet(name string, values ...string)

RegisterSet is the common case: a custom type that picks uniformly from a fixed set of values (e.g. movie titles for a "cinema" type).

synth.RegisterSet("cinema", "Inception", "Interstellar", "Tenet", "Dune")

func Transactions

func Transactions(n int, opts ...Option) ([]map[string]any, error)

func TryMake

func TryMake[T any](n int, opts ...Option) ([]T, error)

TryMake is Make that returns configuration errors instead of panicking.

func Users

func Users(n int, opts ...Option) ([]map[string]any, error)

Users, Payments and the rest are shorthands for the common presets.

func Warnings

func Warnings[T any]() []schema.Warning

Warnings returns the fields Synth could not infer for type T (left as zero).

func WriteCDC

func WriteCDC[T any](path string, n int, cfg CDCConfig) error

WriteCDC writes n change events for type T to a JSONL file.

func WriteCSV

func WriteCSV[T any](path string, records []T) error

WriteCSV writes records to a CSV file. Column order and header come from the struct's field order.

func WriteJSONL

func WriteJSONL[T any](path string, records []T) error

WriteJSONL writes one JSON object per line.

func WriteSQL

func WriteSQL[T any](path, table string, records []T) error

WriteSQL writes INSERT statements for the given table. This produces a FILE — Synth never connects to a database; run the file with your own tool.

Types

type APISpec

type APISpec struct {
	// contains filtered or unexported fields
}

APISpec wraps a parsed OpenAPI spec for payload generation.

func OpenAPI

func OpenAPI(path string) (*APISpec, error)

OpenAPI loads an OpenAPI 3 spec (YAML or JSON) from a file.

func OpenAPIBytes

func OpenAPIBytes(data []byte) (*APISpec, error)

OpenAPIBytes parses an OpenAPI 3 spec from bytes.

func (*APISpec) Payload

func (a *APISpec) Payload(method, path string, opts ...Option) (map[string]any, error)

Payload generates one valid request-body payload (as a field→value map) for the given method and path.

func (*APISpec) PayloadJSON

func (a *APISpec) PayloadJSON(method, path string, opts ...Option) ([]byte, error)

PayloadJSON generates one payload and marshals it to indented JSON.

func (*APISpec) Payloads

func (a *APISpec) Payloads(method, path string, n int, opts ...Option) ([]map[string]any, error)

Payloads generates n valid request-body payloads for method+path.

type CDCConfig

type CDCConfig = cdc.Config

CDCConfig controls a generated change-event history.

type CDCEvent

type CDCEvent = cdc.Event

CDCEvent is one Debezium-shaped change event.

type CDCStream

type CDCStream = cdc.Stream

CDCStream produces insert/update/delete events over a schema.

func CDC

func CDC[T any](cfg CDCConfig) (*CDCStream, error)

CDC builds a deterministic change-event stream for type T. The history is coherent: a row is inserted before it is updated, updates carry the true `before` image, and deleted rows are never touched again.

s, _ := synth.CDC[User](synth.CDCConfig{Table: "users", UpdateRate: 0.3, DeleteRate: 0.1})
s.WriteJSONL(os.Stdout, 1000)

type CascadeConfig

type CascadeConfig = cdc.CascadeConfig

CascadeConfig controls a two-table change stream with cascade deletes.

type CascadeStream

type CascadeStream = cdc.CascadeStream

CascadeStream produces an interleaved change stream over a parent and a child table, where deleting a parent deletes its children too.

type Config

type Config struct {
	Seed   uint64
	Locale string // "uz_UZ", "en_US", ...
}

Config configures a standalone Generator.

type DDLTable

type DDLTable struct {
	// contains filtered or unexported fields
}

DDLTable is a table parsed from SQL DDL, ready to generate rows.

func DDLBytes

func DDLBytes(sql []byte) ([]*DDLTable, error)

DDLBytes parses CREATE TABLE statements from SQL text.

func LoadDDL

func LoadDDL(path string) ([]*DDLTable, error)

LoadDDL parses CREATE TABLE statements from a .sql file. Synth reads the DDL as text — it never connects to a database.

func (*DDLTable) Columns

func (d *DDLTable) Columns() []string

Columns returns column names in declaration order.

func (*DDLTable) Generate

func (d *DDLTable) Generate(n int, opts ...Option) ([]map[string]any, error)

Generate produces n rows as field→value maps.

func (*DDLTable) Name

func (d *DDLTable) Name() string

Name returns the table name.

type Env added in v1.7.0

type Env interface {
	R
	// From returns the value of the field named by this field's from=, or nil
	// when it has none. It is how a provider stays coherent with a sibling:
	// a device name reads the model code it must match.
	From() any
	// Sibling returns an already-generated field of the same record by name,
	// or nil when that field does not exist or has not been generated yet.
	// Field order follows the from=/match=/derive= dependency graph, so a
	// provider that needs a sibling should be declared with from=.
	Sibling(name string) any
	// LocaleName is the record's locale, e.g. "uz_UZ".
	LocaleName() string
	// CountryCode is the record locale's international dialling prefix with
	// its leading '+', e.g. "+998".
	CountryCode() string
	// PhonePrefix is the operator or area digits of the place this record was
	// given, e.g. "90" for Tashkent or "213" for Los Angeles. It is what keeps
	// a generated number in the same city as the record's address.
	PhonePrefix() string
}

Env is the wider surface a custom provider may reach for when randomness alone is not enough: the record's locale, and the fields already generated for it. The R handed to a provider always satisfies it, so a provider that needs more than R asserts for it:

synth.Register("device_brand", func(r synth.R) any {
    env, ok := r.(synth.Env)
    ...
})

It is a separate interface rather than more methods on R so that adding to it later does not break providers written against R.

type Generator

type Generator struct {
	// contains filtered or unexported fields
}

Generator is a stateful, per-instance value source. Each Generator owns its own RNG, so different goroutines using different Generators never contend — unlike a package-level faker guarded by a global mutex. Not safe for concurrent use by ONE Generator; give each goroutine its own.

func New

func New(cfg ...Config) *Generator

New returns a Generator. A zero Config uses a random seed and en_US.

func (*Generator) Amount

func (g *Generator) Amount(min, max int) float64

Amount returns a monetary value in [min,max].

func (*Generator) Card

func (g *Generator) Card() string

func (*Generator) City

func (g *Generator) City() string

func (*Generator) Company

func (g *Generator) Company() string

func (*Generator) Country

func (g *Generator) Country() string

func (*Generator) Currency

func (g *Generator) Currency() string

func (*Generator) Email

func (g *Generator) Email() string

func (*Generator) FirstName

func (g *Generator) FirstName() string

func (*Generator) IBAN

func (g *Generator) IBAN() string

func (*Generator) IPv4

func (g *Generator) IPv4() string

func (*Generator) Name

func (g *Generator) Name() string

Convenience single-value accessors. Each reuses the Generator's RNG.

func (*Generator) Phone

func (g *Generator) Phone() string

func (*Generator) Postcode

func (g *Generator) Postcode() string

func (*Generator) Region

func (g *Generator) Region() string

func (*Generator) URL

func (g *Generator) URL() string

func (*Generator) Username

func (g *Generator) Username() string

type MaskReport

type MaskReport = mask.Report

MaskReport summarizes what a masking run changed.

type MaskRule

type MaskRule = mask.Rule

MaskRule binds a column to a masking strategy.

type Masker

type Masker = mask.Masker

Masker anonymizes a real data export, replacing personal data with synthetic values of the same format. See the mask package for the guarantees.

func NewMasker

func NewMasker(key, localeName string) *Masker

NewMasker returns a Masker. The key makes replacements deterministic: mask related dumps with the same key so foreign keys still join, or use a fresh key to make two runs unlinkable. Columns that look personal are faked by default even without an explicit rule.

type Option

type Option func(*config)

Option configures a generation call.

func Offset

func Offset(n int) Option

Offset starts row generation at record index n instead of 0.

Each row is seeded from its index, so the output is a deterministic function of the index. Offsetting the index is what lets a second run extend a first one: Offset(1000) produces rows 1000..1000+n, which differ from the first run's rows 0..999 yet stay reproducible. This is the mechanism behind the CLI's --append.

func Ref

func Ref[P any](parents []P, fkField string, opts ...RefOption) Option

Ref links a foreign-key field on the child to a parent slice, so every child row points at a real parent. Pass OneToMany to control cardinality.

func RefValues

func RefValues(fkField string, values []any) Option

RefValues links a foreign-key field to values the caller already holds, rather than to a parent slice generated in the same process. This is the cross-run case: the parent was written to a file in an earlier run, its key column read back, and passed here so the child points at rows that already exist on disk.

users, _ := synth.Users(10000)                      // run 1, written out
keys := readColumn("users.csv", "id")               // read back later
orders, _ := spec.GenerateN(500000, synth.RefValues("user_id", keys))

A nil or empty values slice is a no-op: with no parent keys there is nothing to point at, and the field generates as it otherwise would.

func Unmasked

func Unmasked() Option

Unmasked strips the mask= setting from every field, returning raw values.

The name is deliberate: at the call site it reads as a decision, not a default. Output from this option must not be pasted anywhere a real value would be unwelcome.

func Weighted

func Weighted(field string, choices map[string]float64) Option

Weighted turns a field into a weighted enum in code (an alternative to the `synth:"enum,choices=...,weights=..."` tag). Weights need not sum to 1.

synth.Weighted("Status", map[string]float64{"settled":0.94,"pending":0.05,"failed":0.01})

func WithChaos

func WithChaos(p float64) Option

WithChaos makes a fraction p (0..1) of string/numeric fields carry an edge-case value — empty strings, emoji, RTL text, SQL/HTML fragments, pathologically long input, boundary numerics. Use it to test the paths the happy path never reaches. Referential-key fields are never corrupted.

func WithLocale

func WithLocale(name string) Option

WithLocale selects a locale ("uz_UZ", "en_US", ...).

func WithSeed

func WithSeed(seed uint64) Option

WithSeed sets the base seed for deterministic output.

type Preset

type Preset string

Preset names a built-in schema.

const (
	PresetUser        Preset = "user"
	PresetPayment     Preset = "payment"
	PresetTransaction Preset = "transaction"
	PresetOrder       Preset = "order"
	PresetProduct     Preset = "product"
	PresetEmployee    Preset = "employee"
	PresetPatient     Preset = "patient"
	PresetEvent       Preset = "event"
)

func Presets

func Presets() []Preset

Presets lists every built-in schema.

type Profiled

type Profiled struct {
	// contains filtered or unexported fields
}

Profiled is a schema learned from a real-data sample (see the profile package). Generating from it produces synthetic rows whose shape — types, ranges and category frequencies — matches the sample, without ever copying the original data.

func Profile

func Profile(path string) (*Profiled, error)

Profile learns a schema from a CSV or JSONL export of real data. Synth reads the file only; it never connects to a database.

func ProfileBytes

func ProfileBytes(data []byte, format string) (*Profiled, error)

ProfileBytes is Profile for data already in memory, with the format named rather than read from a file extension. Callers that must not touch the filesystem — the MCP server is one — use this.

It takes bytes rather than an io.Reader because profiling and constraint mining each need their own pass over the data: profiling streams and keeps only statistics, while an invariant is a relationship between whole rows. A single reader cannot be consumed twice, and buffering it here would hide the memory cost from the caller who chose to hold the data in the first place.

format is "csv" (the default) or "jsonl"/"ndjson".

func (*Profiled) Columns

func (p *Profiled) Columns() []string

Columns returns the profiled column names in file order.

func (*Profiled) Constraints

func (p *Profiled) Constraints() []constraint.Constraint

Constraints returns the cross-column invariants mined from the sample.

func (*Profiled) Generate

func (p *Profiled) Generate(n int, opts ...Option) ([]map[string]any, error)

Generate produces n synthetic rows matching the profiled shape.

func (*Profiled) SampleRows

func (p *Profiled) SampleRows() int

SampleRows returns how many rows were profiled.

func (*Profiled) Stats

func (p *Profiled) Stats() map[string]*profile.ColumnStats

Stats exposes the observed per-column statistics (distinct counts, null counts, numeric ranges) so you can inspect what was learned.

func (*Profiled) YAML

func (p *Profiled) YAML(name string, count int) ([]byte, error)

YAML renders the profiled schema as a YAML spec, the same dialect yamlfe parses. This closes the loop: profile a real export once, keep the spec in version control, and generate from it forever after without the original data.

type ProtoMessage

type ProtoMessage struct {
	// contains filtered or unexported fields
}

ProtoMessage is a message parsed from a .proto file, ready to generate rows.

func LoadProto

func LoadProto(path string) ([]*ProtoMessage, error)

LoadProto parses .proto source from a file. Synth reads it as text — no protoc, no code generation, no schema registry.

func ProtoBytes

func ProtoBytes(src []byte) ([]*ProtoMessage, error)

ProtoBytes parses .proto source from memory.

func (*ProtoMessage) Columns

func (p *ProtoMessage) Columns() []string

Columns returns field names in declaration order.

func (*ProtoMessage) Generate

func (p *ProtoMessage) Generate(n int, opts ...Option) ([]map[string]any, error)

Generate produces n records matching the message.

func (*ProtoMessage) Name

func (p *ProtoMessage) Name() string

Name returns the message name.

type R

type R interface {
	// Intn returns a value in [0,n).
	Intn(n int) int
	// IntRange returns a value in [min,max].
	IntRange(min, max int) int
	// Float64 returns a value in [0,1).
	Float64() float64
	// Pick returns a random element of s (empty string if s is empty).
	Pick(s []string) string
	// Digits returns n random decimal digits.
	Digits(n int) string
}

R is the minimal randomness surface handed to a custom provider. It keeps user code decoupled from Synth internals while staying deterministic (it is the record's own seeded stream).

type RateConfig

type RateConfig struct {
	// PerSecond is the target event rate. Values <= 0 mean "as fast as
	// possible" (no pacing).
	PerSecond float64
	// Burst is how many events are emitted per tick. Larger bursts mean fewer,
	// coarser wakeups; 0 defaults to 1.
	Burst int
	// Jitter (0..1) randomizes each interval by up to ±Jitter, so arrivals look
	// like real traffic instead of a metronome.
	Jitter float64
	// Total caps how many events are emitted. 0 means run until the context is
	// cancelled.
	Total int
}

RateConfig paces a generated stream so it arrives over wall-clock time, the way a real event source would. Use it to drive streaming tests, load tests and consumer back-pressure experiments without a broker.

type RateStream

type RateStream[T any] struct {
	// contains filtered or unexported fields
}

RateStream emits records at a wall-clock rate.

func Rate

func Rate[T any](cfg RateConfig, opts ...Option) *RateStream[T]

Rate prepares a paced stream of T. Nothing is generated until Run is called.

synth.Rate[Event](synth.RateConfig{PerSecond: 500, Total: 10_000}).
    Run(ctx, func(e Event) error { return producer.Send(e) })

func (*RateStream[T]) Run

func (r *RateStream[T]) Run(ctx context.Context, fn func(T) error) error

Run generates records and hands each to fn at the configured rate. It stops on the first error from fn, when Total is reached, or when ctx is cancelled (returning ctx.Err()).

type RefOption

type RefOption func(*refSpec)

RefOption tunes a Ref.

func OneToMany

func OneToMany(min, max int) RefOption

OneToMany makes each parent own between min and max children (best-effort; applied by drawing FK values proportionally). Currently distributes uniformly at random within the range semantics.

type SchemaFile

type SchemaFile struct {
	// contains filtered or unexported fields
}

SchemaFile is a record definition parsed from a JSON Schema or Avro schema.

func AvroBytes

func AvroBytes(data []byte) (*SchemaFile, error)

AvroBytes parses an Avro record schema.

func JSONSchemaBytes

func JSONSchemaBytes(data []byte) (*SchemaFile, error)

JSONSchemaBytes parses a JSON Schema document.

func LoadSchema

func LoadSchema(path string) (*SchemaFile, error)

LoadSchema parses a JSON Schema or Avro schema file (detected by content).

func (*SchemaFile) Columns

func (s *SchemaFile) Columns() []string

Columns returns field names in declaration order.

func (*SchemaFile) Generate

func (s *SchemaFile) Generate(n int, opts ...Option) ([]map[string]any, error)

Generate produces n records matching the schema.

func (*SchemaFile) Name

func (s *SchemaFile) Name() string

Name returns the record name (JSON Schema title / Avro record name).

type SnapshotConfig

type SnapshotConfig = snapshot.Config

SnapshotConfig describes a table's life across time.

type Streamer

type Streamer[T any] struct {
	// contains filtered or unexported fields
}

Streamer generates n records lazily and writes them straight to a sink, never holding more than one record in memory — for 100M-row runs.

func Stream

func Stream[T any](n int, opts ...Option) *Streamer[T]

Stream prepares a lazy generation of n records of type T.

func (*Streamer[T]) Each

func (s *Streamer[T]) Each(fn func(T) error) error

Each calls fn for every generated record, one at a time (constant memory).

func (*Streamer[T]) ToCSV

func (s *Streamer[T]) ToCSV(path string) error

ToCSV streams records into a CSV file in constant memory.

func (*Streamer[T]) ToJSONL

func (s *Streamer[T]) ToJSONL(path string) error

ToJSONL streams records into a JSONL file in constant memory.

type Timeline

type Timeline = snapshot.Timeline

Timeline answers what a table looked like at any instant, and what changed between two of them.

func Snapshot

func Snapshot[T any](cfg SnapshotConfig) (*Timeline, error)

Snapshot builds a timeline for type T. Ask it for the table as of any instant, or for the change events between two — applying the events to the earlier snapshot reproduces the later one exactly.

tl, _ := synth.Snapshot[Order](synth.SnapshotConfig{Rows: 10_000, Churn: 2})
jan := tl.At(time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC))
events := tl.Between(jan1, jul1)

type YAMLSpec

type YAMLSpec struct {
	// contains filtered or unexported fields
}

YAMLSpec is a parsed declarative data definition (see the yamlfe package).

func LoadYAML

func LoadYAML(path string) (*YAMLSpec, error)

LoadYAML parses a YAML data-definition file.

func Spec

func Spec(p Preset) (*YAMLSpec, error)

Spec turns a preset into an editable spec, for when the built-in shape is close but not exact.

func YAMLBytes

func YAMLBytes(data []byte) (*YAMLSpec, error)

YAMLBytes parses a YAML data definition from bytes.

func (*YAMLSpec) CDC

func (y *YAMLSpec) CDC(cfg CDCConfig) (*CDCStream, error)

CDCFromSpec builds a change-event stream from a YAML spec rather than a Go type, so the CLI can generate a history without compiled structs.

func (*YAMLSpec) Cascade

func (y *YAMLSpec) Cascade(child *YAMLSpec, cfg CascadeConfig) (*CascadeStream, error)

Cascade builds a two-table change stream from this spec (the parent) and a child spec, where deleting a parent cascades to its children. The schemas are copied so the streams do not mutate the parsed specs.

func (*YAMLSpec) Columns

func (y *YAMLSpec) Columns() []string

Columns returns the field names in declaration order (for CSV/SQL headers).

func (*YAMLSpec) Constraints

func (y *YAMLSpec) Constraints() []constraint.Constraint

Constraints returns the spec's cross-column invariants.

func (*YAMLSpec) Count

func (y *YAMLSpec) Count() int

Count returns the number of rows the spec requests.

func (*YAMLSpec) Generate

func (y *YAMLSpec) Generate(opts ...Option) ([]map[string]any, error)

Generate produces the spec's records as field→value maps. Options override the spec's seed/locale when provided.

func (*YAMLSpec) GenerateN

func (y *YAMLSpec) GenerateN(n int, opts ...Option) ([]map[string]any, error)

GenerateN generates exactly n records, overriding the spec's own count.

func (*YAMLSpec) Name

func (y *YAMLSpec) Name() string

Name returns the spec's declared dataset name (used as an SQL table name).

func (*YAMLSpec) Schema

func (y *YAMLSpec) Schema() *schema.Schema

Schema returns the parsed schema. Packages that build on the fields rather than on generated rows — snapshot and constraint mining — need it.

func (*YAMLSpec) SetCount

func (y *YAMLSpec) SetCount(n int)

SetCount overrides how many rows the spec produces.

func (*YAMLSpec) Snapshot

func (y *YAMLSpec) Snapshot(cfg SnapshotConfig) (*Timeline, error)

Snapshot builds a timeline from a YAML spec rather than a Go type, so the CLI can travel through time without compiled structs.

Directories

Path Synopsis
Package cdc emits a deterministic stream of change events — insert, update, delete — in Debezium's envelope shape.
Package cdc emits a deterministic stream of change events — insert, update, delete — in Debezium's envelope shape.
cmd
synth module
Package constraint mines cross-column invariants from a real sample and enforces them during generation.
Package constraint mines cross-column invariants from a real sample and enforces them during generation.
Package ddlfe is a frontend that parses SQL CREATE TABLE statements (from a .sql file or migration) into Synth schemas.
Package ddlfe is a frontend that parses SQL CREATE TABLE statements (from a .sql file or migration) into Synth schemas.
Package diff compares the shape of two datasets — their columns, types, numeric ranges, null rates and category sets — rather than their rows.
Package diff compares the shape of two datasets — their columns, types, numeric ranges, null rates and category sets — rather than their rows.
Package dist provides statistical distributions so generated data has the shape production data does — skew, hot keys, long tails — instead of the uniform noise most fakers produce.
Package dist provides statistical distributions so generated data has the shape production data does — skew, hot keys, long tails — instead of the uniform noise most fakers produce.
examples
basic command
Command basic demonstrates Synth as a pure data provider: structs in, coherent records out — to memory, a file, and a stream.
Command basic demonstrates Synth as a pure data provider: structs in, coherent records out — to memory, a file, and a stream.
images command
Command images demonstrates the drawn-image kinds: an avatar that belongs to the person in the same row, a thumbnail that belongs to the product, and a company mark that belongs to the company.
Command images demonstrates the drawn-image kinds: an avatar that belongs to the person in the same row, a thumbnail that belongs to the product, and a company mark that belongs to the company.
localize command
Command localize shows two things at once: a complex struct with a nested sub-struct, and locale coherence — the same schema rendered per locale, where name, phone, address and card all agree with the chosen region.
Command localize shows two things at once: a complex struct with a nested sub-struct, and locale coherence — the same schema rendered per locale, where name, phone, address and card all agree with the chosen region.
Package gen is the engine: schema.Schema + rng → records.
Package gen is the engine: schema.Schema + rng → records.
Package imagegen renders small, deterministic images from a name and a seed.
Package imagegen renders small, deterministic images from a name and a seed.
Package infer turns an untagged field (its name + Go type) into a schema.Kind.
Package infer turns an untagged field (its name + Go type) into a schema.Kind.
internal
rng
Package rng provides a fast, per-instance random source.
Package rng provides a fast, per-instance random source.
webspec
Package webspec holds the pieces the workbench needs on both sides of its two backends: the local HTTP server and the WebAssembly build that runs the same page with no server at all.
Package webspec holds the pieces the workbench needs on both sides of its two backends: the local HTTP server and the WebAssembly build that runs the same page with no server at all.
Package locale holds locale-coherent datasets.
Package locale holds locale-coherent datasets.
Package mask anonymizes a real data export: it replaces personal data with synthetic values while preserving the FORMAT and the referential structure of the original, so the result still exercises the same code paths.
Package mask anonymizes a real data export: it replaces personal data with synthetic values while preserving the FORMAT and the referential structure of the original, so the result still exercises the same code paths.
Package openapi is a frontend that turns an OpenAPI 3 spec into Synth schemas, so you can generate valid request payloads for an endpoint without hand-writing a struct.
Package openapi is a frontend that turns an OpenAPI 3 spec into Synth schemas, so you can generate valid request payloads for an endpoint without hand-writing a struct.
Package pgcopy writes generated rows in the two formats Postgres COPY accepts, which is the fast way to get bulk data into a table: an INSERT statement per row is the slowest path the server offers, and at the volumes Synth targets the difference is hours.
Package pgcopy writes generated rows in the two formats Postgres COPY accepts, which is the fast way to get bulk data into a table: an INSERT statement per row is the slowest path the server offers, and at the volumes Synth targets the difference is hours.
Package profile learns a schema from a SAMPLE FILE of real data (a CSV or JSONL export) and produces a Synth schema that reproduces its shape: column types, null rates, numeric ranges, and — for low-cardinality columns — the observed value set with its real frequencies.
Package profile learns a schema from a SAMPLE FILE of real data (a CSV or JSONL export) and produces a Synth schema that reproduces its shape: column types, null rates, numeric ranges, and — for low-cardinality columns — the observed value set with its real frequencies.
Package protofe parses .proto files (proto2/proto3) into Synth schemas.
Package protofe parses .proto files (proto2/proto3) into Synth schemas.
Package providers holds atomic value generators, one per schema.Kind.
Package providers holds atomic value generators, one per schema.Kind.
Package reflectfe is the struct frontend: it turns a Go type + `synth:` tags into a schema.Schema.
Package reflectfe is the struct frontend: it turns a Go type + `synth:` tags into a schema.Schema.
Package schema defines the intermediate representation (IR) that Synth's engine consumes.
Package schema defines the intermediate representation (IR) that Synth's engine consumes.
Package schemafe parses JSON Schema and Avro schema documents into Synth schemas.
Package schemafe parses JSON Schema and Avro schema documents into Synth schemas.
Package snapshot makes time an explicit axis of Synth's determinism.
Package snapshot makes time an explicit axis of Synth's determinism.
Package ui serves a local browser workbench for designing a schema and seeing the data it produces.
Package ui serves a local browser workbench for designing a schema and seeing the data it produces.
Package verify audits an existing dataset.
Package verify audits an existing dataset.
Command synth-wasm runs the workbench entirely in the browser.
Command synth-wasm runs the workbench entirely in the browser.
Package yamlfe is a structless frontend: it builds a Synth schema from a YAML document, so data can be described declaratively (and driven from the CLI) without writing Go types.
Package yamlfe is a structless frontend: it builds a Synth schema from a YAML document, so data can be described declaratively (and driven from the CLI) without writing Go types.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL