Configuration
CloneDB is driven by a single clonedb.toml. It’s generated from your schema and safe
to hand-edit; clonedb sync-config reconciles it with schema changes while preserving
your edits and comments.
The guiding idea: relationships are introspected, not enumerated. The engine walks the live foreign-key graph automatically. The config holds only what introspection can’t know — your filters, a little follow/boundary policy, virtual FKs, and connection details.
The blocks, top to bottom:
| Block | Purpose |
|---|---|
[meta] | Drift-detection metadata (maintained by sync-config). |
[connections.*] | A named pool of databases; each names a credential provider for its secret and can tune session settings. |
default_source / default_target | Top-level keys: which connection a bare run reads from / writes to. |
env_file | Top-level key: an opt-in .env file to load into the environment before resolving credentials. |
[schemas] | Which schemas participate. Required, even if empty. |
[defaults] | Policy applied to every table unless overridden. |
[tables.*] | Per-table seeds, filters, and policy. |
[scopes.*] | Named, persisted seed sets for day-to-day work. |
[virtual_foreign_keys] | Relationships with no DB constraint (terse map + [[virtual_foreign_key_rules]]). |
[[relationship_overrides]] | Stop a downstream cascade between two tables. |
[meta]
Section titled “[meta]”Drift-detection metadata. Maintained by sync-config — you rarely touch it by hand, and
the whole block may be omitted from a hand-written config (the hash falls back to an empty
string, populated on the next sync).
[meta]schema_hash = "sha256:8f3c9a2b…" # hash of the introspected schema, for drift detection| Key | Default | Description |
|---|---|---|
schema_hash | "" | Hash of the introspected schema (sha256:<hex>), for drift detection. |
Connections
Section titled “Connections”Connections are a named pool. Each [connections.<name>] block describes one database:
its non-secret target (host/port/db/user/sslmode) plus a single […auth] block whose
provider supplies the secret. Secrets never live in the config. Names are arbitrary —
production, staging, dev, local — and source/target are just conventional
defaults. A run moves data between any two connections (see
Named connections & defaults).
[connections.source]host = "app-replica.abc123.us-east-1.rds.amazonaws.com"port = 5432 # defaults to 5432 when omitteddatabase = "app"user = "readonly"sslmode = "verify-full" # verify-full for a direct cloud host; require through a tunnel
[connections.source.auth] provider = "aws_secret" # password from AWS Secrets Manager region = "us-east-1" secret = "app/db/readonly" field = "password" # JSON key; omit for a raw-string secretThe simplest target is a full URL (including password) from an env var:
[connections.target] [connections.target.auth] provider = "env" url_env = "CLONEDB_TARGET_URL" # postgres://user:pass@host:5432/app_dev?sslmode=requireNamed connections & defaults
Section titled “Named connections & defaults”Define as many connections as you like, then point two top-level keys at them.
default_source/default_target must come before the [connections.*] blocks —
they’re top-level keys, and TOML would otherwise scope them into the preceding connection
table (clonedb errors loudly if they’re misplaced):
default_source = "production" # what a bare `run` reads fromdefault_target = "local" # what a bare `run` writes to
[connections.production]host = "prod-replica.abc123.us-east-1.rds.amazonaws.com"database = "app"user = "readonly" [connections.production.auth] provider = "aws_secret" region = "us-east-1" secret = "prod/db/readonly"
[connections.local] [connections.local.auth] provider = "env" url_env = "LOCAL_DATABASE_URL"clonedb run resolves each side independently:
| Side | Resolution order |
|---|---|
| source | --from <name|url> → default_source → a connection named source → $CLONEDB_SOURCE_URL |
| target | --to <name|url> (or --target) → default_target → a connection named target |
--from/--to take either a connection name or a raw URL, so you can move between any
two named connections (clonedb run --from production --to staging) or override with a
one-off URL. default_source/default_target are optional — omit them and rely on the
source/target convention or the flags.
Connection fields
Section titled “Connection fields”These non-secret fields describe where to connect. They apply only to component-mode
providers, the ones that fetch just a password or token and combine it with these fields
(see Auth providers). A full-URL provider (url, env with
url_env, dotenv, script with emits = "url") takes host, port, database, user and
TLS settings from the URL itself and ignores these fields. (The
session settings below are different: they apply to every provider.)
| Field | Default | Description |
|---|---|---|
host | — | Non-secret connection host. |
port | 5432 | Non-secret connection port. |
database | — | Non-secret database name. |
user | — | Non-secret database user. |
sslmode | inferred | TLS mode: disable / require / prefer / verify-ca / verify-full. |
sslrootcert | — | Path to a CA-certificate PEM (e.g. a private CA) that extends the trust store for verify-ca / verify-full. Ignored by the non-verifying modes. |
CloneDB follows libpq’s sslmode semantics, so it behaves like psql:
| Mode | Behavior |
|---|---|
disable | No encryption. |
require / prefer | Encrypt, but do not validate the certificate. Matches libpq — this is what lets CloneDB talk to servers with self-signed certs (e.g. AlloyDB, whose leaf cert is a CA). sslrootcert is ignored here. |
verify-ca | Encrypt and verify the certificate chain, but skip the hostname check (so a tunnelled loopback whose cert names the real instance still verifies). Uses sslrootcert if set. |
verify-full | Verify the chain and the hostname (strict). Uses sslrootcert if set. |
For a component-mode provider, an omitted sslmode is inferred from the host: a
loopback/tunnel host (127.0.0.1, ::1, localhost) defaults to require, a direct host
to verify-full. A full URL with no sslmode behaves like libpq’s default (prefer): it
encrypts when the server supports it but does not verify the certificate. Put
?sslmode=verify-full (and &sslrootcert=/path/ca.pem if needed) in the URL to verify.
Auth providers
Section titled “Auth providers”Pick exactly one provider per connection in its [connections.<name>.auth] block. All
providers coexist; the right one depends on where the secret lives.
| Provider | Yields | Key fields | Needs host/database/user |
|---|---|---|---|
url | full URL (stored inline) | url | no |
env | full URL or password | url_env / password_env | only with password_env |
dotenv | full URL | url_env, path | no |
aws_secret | static password | region, secret, field | yes |
aws_iam | dynamic token (~15m) | region | yes |
gcp_secret | static password | secret, field | yes |
gcp_iam | dynamic token (~60m) | none | yes (user is the IAM principal) |
script | password or URL | command, emits | only with emits = "password" |
A provider that needs the connection fields fails with a clear error if host, database
or user is missing; port defaults to 5432.
url stores the whole connection string inline in the config — including the password.
It’s the quickest option for a local or throwaway database; for anything sensitive use a
provider that fetches the secret (and keep a url config out of version control). env
takes either url_env (a full URL) or password_env (component mode — password only, with
the host/port/etc. from the target fields above). For the rest, which fields are required
vs. optional and their defaults are spelled out in the full table below.
All auth fields:
| Field | Providers | Description |
|---|---|---|
provider | all | The provider scheme (one of the above). |
url | url | Full connection URL stored inline in the config (includes the password — for local/dev). |
url_env | env, dotenv | Env var holding a full connection URL. |
path | dotenv | Path to the .env file (defaults to ./.env). |
password_env | env | Component mode — env var holding just the password (host/port/etc. come from the target fields above). |
region | aws_iam, aws_secret | The AWS region. |
secret | aws_secret, gcp_secret | The secret name/identifier. |
field | aws_secret, gcp_secret | JSON key to extract (default password; omit for a raw-string secret). Alias: secret_field. |
command | script | The command (argv array) to run. |
emits | script | What the command prints: password (default) or url. |
The two IAM providers (aws_iam, gcp_iam) mint a dynamic token refreshed on each
run (and each --watch tick); the rest yield a static secret.
aws_secret, gcp_secret and gcp_iam shell out to the aws / gcloud CLI, so that CLI
must be installed and on your PATH, and they reuse whatever session it already has.
aws_iam signs its token locally and needs no CLI. Every external command (these CLIs and
script) is killed if it hasn’t finished after 30 seconds.
env vs dotenv
Section titled “env vs dotenv”Both read a variable, but from different places:
envreads from the process environment ($VAR). It reads no file itself — the variable must already be exported (your shell, CI,direnv) or loaded via env-file auto-load. Useurl_envfor a whole URL, orpassword_envplus the connection’s host/port/etc. for password-only.dotenvreads a variable directly from a.envfile (path, default.env), without mutating the process environment. Whole-URL only; each connection can point at its own file.
Rule of thumb: secret already in your environment → env; secret in a specific file →
dotenv; one .env with everything for local dev → env (or the $CLONEDB_SOURCE_URL
fallback) plus env-file auto-load.
RDS and Aurora support both a stored password in Secrets Manager and passwordless IAM auth.
aws_secret runs aws secretsmanager get-secret-value, so anything the aws CLI can use
works: aws sso login, a profile, or environment credentials.
# AWS Secrets Manager: static password (most common)[connections.source.auth]provider = "aws_secret"region = "us-east-1"secret = "app/db/readonly" # secret name or ARNfield = "password" # JSON key; omit for a raw-string secretaws_iam signs an RDS auth token itself. It reads credentials only from the
AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY and (optional) AWS_SESSION_TOKEN environment
variables. It does not read ~/.aws profiles or the SSO cache, so after aws sso login
export the session into your shell first:
eval "$(aws configure export-credentials --format env)"The user must be a database user granted the rds_iam role. A complete block:
[connections.source]host = "app.abc123.us-east-1.rds.amazonaws.com"database = "app"user = "clonedb_iam"sslmode = "verify-full"
[connections.source.auth] provider = "aws_iam" region = "us-east-1"GCP providers run the gcloud CLI and use Application Default Credentials
(gcloud auth application-default login). Cloud SQL and AlloyDB support both:
# GCP Secret Manager: static password[connections.source.auth]provider = "gcp_secret"secret = "app-db-readonly" # name, or projects/<p>/secrets/<n>/versions/latestfield = "password" # JSON key; omit for a raw-string secretgcp_iam uses an ADC OAuth token as the password, which only works when IAM database
authentication is enabled on the instance. Set user to the IAM principal: the account’s
email, or for a service account the email without the .gserviceaccount.com suffix.
[connections.source]host = "127.0.0.1" # e.g. through the Cloud SQL Auth Proxyport = 5432database = "app"user = "clonedb-reader@my-project.iam"
[connections.source.auth] provider = "gcp_iam"Script (Vault, etc.)
Section titled “Script (Vault, etc.)”Run any command that prints the password (or a full URL with emits = "url"):
[connections.source.auth]provider = "script"command = ["vault", "read", "-field=password", "secret/db/read"]emits = "password"Session settings
Section titled “Session settings”Seven optional keys on a [connections.<name>] block tune the database session clonedb
opens. The defaults protect a production source: a dead or hung clonedb can’t pin a
snapshot (and block vacuum) for long, and a dead network can’t hang clonedb forever. The
keys are named after the PostgreSQL settings they control.
[connections.production]host = "prod-replica.internal"database = "app"user = "clonedb_ro"application_name = "clonedb" # shows up in pg_stat_activitytcp_keepalives_idle = 60 # secondstcp_keepalives_interval = 10 # secondstcp_keepalives_count = 6# tcp_user_timeout = 120000 # milliseconds; off by default (see below)lock_timeout = "10s"idle_in_transaction_session_timeout = "3min"| Key | Default | Unit | 0 means |
|---|---|---|---|
application_name | "clonedb" | text | (not applicable) |
tcp_keepalives_idle | 60 | seconds | system default |
tcp_keepalives_interval | 10 | seconds | system default |
tcp_keepalives_count | 6 | probes | system default |
tcp_user_timeout | off | milliseconds | off |
lock_timeout | 10s | duration | off |
idle_in_transaction_session_timeout | 3min | duration | off (and no heartbeat) |
A duration is an integer number of milliseconds, or a string of digits with an
optional unit: ms, s, min, h or d, with optional spaces before the unit
("500ms", "10s", "3 min"). A string without a unit is also milliseconds. Fractions,
negative values and other units are rejected, as is anything over 2147483647 ms (about
24.8 days). An invalid value fails the config load with an error naming the key.
An explicit 0 is sent to the server as 0, so it also switches off a default set with
ALTER ROLE or ALTER DATABASE. Any other value clonedb sends, including its built-in
default, replaces such a default for its own sessions.
What each one does:
application_nameis sent when the connection starts and set in every transaction, so you can find clonedb’s sessions inpg_stat_activity, even behind a pooler that doesn’t pass the startup parameter on.clonedb validateflags a name over 63 bytes or with characters outside printable ASCII, since PostgreSQL would truncate or rewrite it.- TCP keepalives (
tcp_keepalives_*) are set on both ends. On the server they let PostgreSQL notice a dead clonedb and end its session, releasing its snapshot. On clonedb’s socket they let clonedb notice a dead server instead of waiting forever. With the defaults, a silent peer is detected in about two minutes (60 s idle + 6 probes × 10 s). The server side has no effect over a Unix-domain socket. tcp_user_timeoutdrops a connection when sent data stays unacknowledged for this long. It is off by default; see below.lock_timeoutmakes a source query that waits more than 10 s for a lock (for example, behind a migration’sACCESS EXCLUSIVElock) fail instead of queueing indefinitely.idle_in_transaction_session_timeoutlets the server end a clonedb session whose transaction sits idle for 3 minutes, which is what frees the snapshot if clonedb hangs or is killed.
Heartbeat
Section titled “Heartbeat”A healthy run has idle transactions too, so clonedb sends SELECT 1 on its source
transactions every min(60 s, idle timeout / 3), and never more often than every
100 ms: every 60 s with the default 3 minutes, every 30 s with 90s. The idle timeout is
the one the server actually has, read from pg_settings when the run starts, so the
heartbeat also follows a value set in a URL’s options.
Every open source transaction is pinged on schedule, busy or not: the transaction that owns the run’s snapshot, every parallel reader, and the main read transaction during collection, the target load and the dump. A ping never waits for an earlier one to return (at most 16 are outstanding), so the heartbeat keeps firing while clonedb works through a result or waits on a slow target. The snapshot owner and the readers keep heartbeating while a run waits at the “write to target?” confirmation prompt.
The idle timeout therefore only fires when clonedb has actually stopped: crashed, been
suspended, or lost its network. If the snapshot owner’s heartbeat fails, the run exits with
an error (“the run snapshot was lost”) rather than reporting success. Setting the timeout
to 0 turns off both the timeout and the heartbeat.
Source vs. target
Section titled “Source vs. target”The source gets all seven settings, with the defaults above. The target gets
application_name and the TCP keys (defaults included), and lock_timeout only if its
connection block sets it; the 10 s default doesn’t apply there. clonedb never sets
idle_in_transaction_session_timeout on a target, even if the block sets it, and never
heartbeats one. (A value in a target URL’s options still reaches the server as written.)
Where values come from
Section titled “Where values come from”For each key: a value in the connection block wins, then the connection URL if it
names the key, then the built-in default. They work with every credential provider,
including full-URL providers and raw --from/--to/--source URLs (which have no
connection block).
clonedb never parses or rewrites URL values. It only checks which keys a URL names, and for each one it leaves the value to the driver or the server and applies no default. Each URL form reaches one side only:
- Query parameters set clonedb’s own socket and startup packet:
application_name,keepalives,keepalives_idle,keepalives_interval,keepalives_retries(libpq’s names; seconds and a count) andtcp_user_timeout(milliseconds). - Server settings go in the
optionsparameter as-c name=value, URL-encoded, and set only the server side.
postgres://clonedb_ro@replica:5432/app?application_name=nightly-clone&options=-c%20lock_timeout%3D5sSo ?keepalives_idle=30 changes clonedb’s socket while the server keeps the 60 s default.
A key set in the connection block drives both sides.
clonedb applies the server settings with set_config(…, true) at the start of every
transaction it opens, after any SET TRANSACTION SNAPSHOT and before the first data query,
not as startup options. They end with the transaction: nothing is set for the session, so
the server keepalives apply while clonedb has a transaction open and nothing reaches a
connection pooler’s other clients. If the server refuses one (for
example, a managed service that doesn’t allow it), clonedb logs a warning naming the
setting once, skips it for the rest of the run, and the others still apply.
tcp_user_timeout
Section titled “tcp_user_timeout”Leave it off, with or without a pooler. On the server side, a COPY out of the source
that is stalled by a slow target sits at a zero TCP window, and Linux aborts the connection
once tcp_user_timeout passes, killing a healthy run. If you do enable it, 120000 (two
minutes) is a reasonable value. On clonedb’s own socket it only takes effect on Linux.
Connection poolers
Section titled “Connection poolers”A direct connection, a session-mode pooler and a transaction-mode pooler (e.g. PgBouncer
pool_mode = session or pool_mode = transaction) all work with no setting: clonedb’s
settings end with each transaction, and it needs no pooler prepared-statement support. The
pooler’s pool must allow parallel_readers + 2 concurrent source server connections and
one target connection, or the readers wait in its queue. Statement-mode pooling is not
supported.
clonedb doesn’t send its server settings as startup options, which PgBouncer rejects by
default. The same isn’t true of an options= you put in a URL: PgBouncer rejects it
unless it’s listed in ignore_startup_parameters. Only application_name goes in the
startup packet, and it’s set in every transaction as well. Behind a pooler, clonedb’s socket keepalives reach only the pooler and the
server keepalives tune the pooler-to-server socket, so the idle timeout and heartbeat
become the main guard. A pooler relays the same back-pressure as a direct connection, so
the tcp_user_timeout advice above still applies.
See Production safety for the full picture.
env-file auto-load
Section titled “env-file auto-load”clonedb does not read a .env file automatically. Opt in with the top-level
env_file key (or the --env-file <FILE> flag, which overrides it):
env_file = ".env" # loaded into the environment before credentials resolveBefore resolving credentials, clonedb loads that file into the process environment, so the
env provider and the $CLONEDB_SOURCE_URL fallback can see its variables. Loading is
non-override — a variable already set in the environment wins — and a named-but-missing
file is a hard error. It does not affect the dotenv provider, which reads its own file
directly.
env_file is a top-level key: like default_source/default_target, place it before
the [connections.*] blocks (clonedb errors if it’s nested inside one). The
init wizard sets it for you and creates the .env, prompting for
each env/dotenv variable’s value as you define the connections.
Schemas
Section titled “Schemas”Which PostgreSQL schemas participate. Cross-schema foreign keys are followed
automatically. exclude wins over include.
The [schemas] header is required: a config without it fails to parse with a
missing-field error. An empty block is fine and means all non-system schemas.
generate-config and init always write it.
[schemas]include = ["public", "billing"] # empty/omitted means "all non-system schemas"# exclude = ["audit"] # drop these even if otherwise included| Key | Description |
|---|---|
include | Allow-list of schema names. Empty means all non-system schemas. |
exclude | Schemas to drop even if included. |
Partitioned tables
Section titled “Partitioned tables”Requires PostgreSQL 12+. clonedb uses
pg_partition_treeto model declarative partitioning and does not fall back for older servers.
A declarative-partition hierarchy is modeled as one logical table at its root.
Introspection keeps the partition root and folds away every partition (leaf and
intermediate), so a partitioned table appears once everywhere — the dry-run plan,
the SQL dump, config virtual-fks infer, and explain — never once per partition.
- Read through the root.
SELECT … FROM rootspans all leaves, so a partitioned table is subset/copied exactly once — no duplicate rows, no duplicate-PK errors on load. - Write through the root. A load
INSERTs into the root and PostgreSQL routes each row into the correct leaf. clonedb loads into an existing target schema, so the target must already have the full partition tree (root + intermediates + leaves); clonedb creates schemas, not tables. - De-inflated estimates. A partitioned root has
pg_class.reltuples = 0(no own storage), so the dry-run estimate sums the leaves’reltuplesfor a correct count. - Name the root, not a partition. Pointing any config reference — a
[tables]block, a seed, a scope, an override, or a virtual-FK endpoint — at a leaf or intermediate partition is a hard error that names the partition and its root. Otherwise partitions are folded silently: a root reads as a normal table. - Target partition pre-flight. Before a live
--toload writes any row, clonedb compares the target’s partitions with the source’s for every partitioned table that receives rows. It warns about each source partition the target is missing (and has noDEFAULTpartition to catch), but only when this run’s rows actually land in that partition, so a target without last year’s partition is fine for a run that copies this month. If a row still has nowhere to go, the load fails with an error naming the table and telling you to create the missing (or aDEFAULT) partition on the target.
Defaults
Section titled “Defaults”Policy applied to every table unless overridden per-table. The whole block (and any field) is optional and falls back to the safe baseline.
[defaults]follow_children = false # don't START a cascade from unmarked seedsfollow_nullable = false # don't traverse nullable FKs unless askedmax_children_per_parent = 5000 # fan-out cap (explosion guard)global_row_limit = 2_000_000 # hard ceiling across the whole runon_missing_parent = "skip" # orphan policy: "skip" | "error"parallel_readers = 4 # parallel snapshot-shared source readersslow_query_warn_secs = 5 # warn on any query slower than this (0 disables)| Key | Default | Description |
|---|---|---|
follow_children | false | Whether an unmarked seed starts a downstream cascade. |
follow_nullable | false | Whether to traverse nullable foreign keys. |
max_children_per_parent | 5000 | Max children collected per parent row (explosion guard). |
global_row_limit | none | Hard ceiling on total keys collected across the run. Omit for no run-wide ceiling. |
on_missing_parent | "skip" | Orphan policy when a referenced parent row is missing: skip or error. |
parallel_readers | 4 | Parallel snapshot-shared source reader connections for the run’s read phases. 1 = fully serial; higher values open that many source connections (each adopts the run snapshot, so results are point-in-time identical). Must be ≥ 1; clamped to pool headroom at run time. Overridden per run by run --parallel-readers. |
slow_query_warn_secs | 5 | Log a warning the moment any single query runs longer than this many seconds (fractional allowed, e.g. 0.5). 0 disables the live warning; the end-of-run slow-query report is emitted either way. |
Reachable-fill: how a table you didn’t configure is treated
Section titled “Reachable-fill: how a table you didn’t configure is treated”There’s one subsetting behaviour — no knob. A table’s fate follows from whether it’s reachable from a seed through the foreign-key graph (real or virtual):
- Reachable → scoped. A table you can walk to from a seed is trimmed to that seed’s closure — only the connected rows are copied.
- Not reachable → whole. A table nothing connects to a seed is pulled entire — unless
the run is bounded by
--scope/--scoped, which skips the unreachable complement instead of cloning it whole. - Excluded → nothing. A table with
exclude = truecopies nothing at all (unless you name it explicitly in a--scope/--seed, which overrides the exclusion for that run).
So you scope a table by connecting it — give it (or something upstream of it) a restriction, or wire up a virtual FK — and you leave a table whole by leaving it disconnected. The natural consequence: a config with no seeds reaches nothing, so every non-excluded table is whole — a full clone. Add a seed to start scoping.
Upstream parents are always pulled for referential integrity (including the parents of a
whole table), so the subset is always referentially complete. A big whole count in
run --dry-run is the signal a table isn’t connected yet — add a virtual FK or a
restriction and watch it flip to a small scoped count (or leave it whole on purpose, e.g. a
reference table).
Tables
Section titled “Tables”A [tables."schema.table"] block holds per-table filters and policy. Table identity
is schema-qualified — tables."public.users", which TOML quotes for you because of
the dot.
A table that carries any restriction (where / ids / date / limit / sql) is a
seed — a starting point. Upstream parents are always pulled (for referential
integrity); downstream children only where a cascade is active.
You don’t list every table. Because relationships are introspected, a table with no
block is pulled by FK traversal as needed — so a generated config contains no per-table
blocks, just a comment listing the available tables (one line per schema). Add a
[tables."schema.table"] block only for a table you want to seed, filter, or drop
(exclude = true). (Run generate-config --all-tables if you’d rather have an explicit
empty block per table.)
# Anchor: a few enterprise accounts and EVERYTHING under them.[tables."public.accounts"]sql = """SELECT id FROM public.accountsWHERE plan_tier = 'enterprise' AND status = 'active'ORDER BY created_at DESCLIMIT 25"""follow_children = true
# Seed by explicit ids, with a column-level scrub.[tables."public.users"]ids = [1001, 1002, 1042]follow_children = trueexclude_columns = ["password_hash", "mfa_secret"]
# Latest-N pattern: order_by + limit selects the most recent rows.[tables."public.audit_events"]order_by = "created_at DESC"limit = 5000Restriction strategies
Section titled “Restriction strategies”These define the seed rows. A table’s seed source is exactly one of where/date,
ids, or sql — setting more than one of those groups is an error. Within the first
group, where and date compose (AND-ed into one predicate). limit is not a seed
source and combines freely with any of them (and with order_by).
| Key | Description |
|---|---|
where | Raw SQL predicate (user-trusted), e.g. "issued_at >= now() - interval '90 days'". Composes with date. |
ids | Explicit primary-key id list, e.g. [1001, 1002, 1042]. |
sql | A full raw SELECT whose result supplies the seed keys. |
date | A date-range restriction on a column — see below. Composes with where. |
limit | Row cap for this table’s seed. Not exclusive — combine with order_by and a seed source. |
The date restriction is an inline table:
[tables."public.events"]date = { column = "created_at", start = "2026-01-01", end = "2026-04-01" }date field | Description |
|---|---|
column | The column the range applies to (required). |
start | Inclusive lower bound (>=). Optional. |
end | Exclusive upper bound (<). Optional. |
Bare ISO date/timestamp literals are quoted for you (start = "2026-01-01" →
created_at >= '2026-01-01'); a value that looks like a SQL expression (now(),
interval, an operator, or already quoted) is passed through verbatim.
Relative seeds and the run clock
Section titled “Relative seeds and the run clock”A relative seed such as where = "created_at > now() - interval '7 days'" is evaluated in
several transactions during one run, and now() normally differs per transaction. To keep
the window fixed, clonedb captures one clock per run (per iteration under --watch)
when it takes the run’s snapshot. Before any query runs, it rewrites every clock call
(now(), transaction_timestamp(), statement_timestamp(), current_timestamp,
current_date, current_time, localtimestamp, localtime) in where, sql, date,
order_by and virtual-FK when values to that instant. String literals, quoted
identifiers and comments are left alone. Truly volatile functions (clock_timestamp(), timeofday(), random()) are
not pinned; they still change on every call.
Seed tables that don’t exist
Section titled “Seed tables that don’t exist”A seed that names a table missing from the introspected schema (a typo, a dropped table, or
a table outside your [schemas] filter) is handled by how much it matters:
- An ad-hoc
run --seedtable that doesn’t exist is always a hard error. - A config or scope seed that doesn’t exist is a hard error when skipping it would lose
data elsewhere: a real FK references it, a virtual FK names it, or it is the only seed of a
--scope/--scopedrun (which would otherwise succeed with an empty clone). - Otherwise the run warns, naming the table, and continues; that seed collects nothing.
A block
sync-confighas already markedpresent_in_schema = falsestays silent.
Per-table policy
Section titled “Per-table policy”| Key | Falls back to | Description |
|---|---|---|
order_by | — | Order-by clause (column + direction). Combine with limit for “the latest N”. |
upsert_key | table PK | Natural-/business-key columns to use as the load-time upsert conflict target. Set this for a table with no primary key (otherwise skipped on load); the columns must match a real UNIQUE constraint. Ignored (with a warning) on a table that already has a PK. |
follow_children | [defaults].follow_children | Override the cascade anchor for this table. |
follow_nullable | [defaults].follow_nullable | Override whether nullable FK child edges into this table are followed. |
on_missing_parent | [defaults].on_missing_parent | Override the orphan policy (skip / error) for this table’s parents. |
exclude_columns | — | Columns to drop (column-level scrub; the masking seam lives here). |
passthrough | false | Copy the table whole and never traverse from it (tiny lookup tables). |
exclude | false | Exclude this table entirely — schema kept, no data. An unconditional cut: the table is never collected, not even as a mandatory FK parent or cascade child. |
# Passthrough: tiny lookup tables copied whole, never traversed.[tables."public.countries"]passthrough = true
# Exclude entirely (schema kept, no data). Preserved across sync-config.[tables."public.access_log"]exclude = true # 400M rows of PII we never want in devBulk exclude / passthrough lists
Section titled “Bulk exclude / passthrough lists”For a table that needs only to be excluded or passed through — no filter, no column
policy — a whole [tables."x"] block is boilerplate. Two top-level arrays of
schema-qualified table names give you the same effect in one line per table:
exclude = ["public.audit_log", "public.sessions", "public.schema_migrations"]passthrough = ["billing.countries", "billing.plans"]These are additive to the per-table booleans: a table is excluded if it’s in the
exclude list or its block sets exclude = true (same for passthrough). The two
forms coexist — config writes the list for new bulk edits and never rewrites your
existing blocks — and exclude wins over passthrough when a table is in both.
| Key | Description |
|---|---|
exclude | Schema-qualified tables to drop entirely (the list form of [tables."x"].exclude = true). |
passthrough | Schema-qualified tables to copy whole and never traverse from (the list form of [tables."x"].passthrough = true). |
validate checks each entry is a real table (an error) and
warns when a table is in both lists, or in a list and a per-table block (harmless —
the forms are additive).
Follow policy
Section titled “Follow policy”follow_children = true anchors a cascade that propagates transitively — child to
grandchild and on — until a stop. You set it once on the anchor, not on every level.
Stops: an explicit follow_children = false, a passthrough table, or a
relationship_overrides entry. Cycles terminate automatically via key dedup. The
[defaults] guards (max_children_per_parent, global_row_limit) bound total size.
Scopes
Section titled “Scopes”Named, focused subsets for day-to-day work — clonedb run --scope checkout. Scope
tables become the seed set and pull upstream parents only (small and predictable)
unless an override re-enables a cascade.
A --scope run is bounded: the scope’s tables become the run’s seeds and only they, their
FK parents, and any cascade an override re-enables are pulled — every other table is
skipped, not cloned whole. This is the key difference from a plain run, where an
unreachable table is pulled
whole. A scope also
neutralizes the config’s own ambient seeds/cascade and its top-level
passthrough list — a passthrough table that isn’t one of
the scope’s tables is not copied under that scope. A table named directly in tables (or via
--seed) is collected even if the config marks it exclude: naming it wins over the exclusion
for that run (validate notes this with an advisory warning, not an error). Use
run --scoped to get the same bounding for a seed-driven run
(config seeds or ad-hoc --seed) without defining a named scope.
[scopes.checkout]description = "Just the checkout tables for the payments refactor"tables = ["public.orders", "public.order_items", "billing.invoices"]
# Per-scope filter overrides, applied only while this scope runs. [scopes.checkout.overrides."public.orders"] where = "created_at >= '2026-06-01'" limit = 500| Key | Description |
|---|---|
description | Human-readable note describing what the scope pulls. |
tables | Schema-qualified tables that become the seed/root set. |
overrides."schema.table" | Per-table filter blocks (table shape) layered over the main config while this scope is active. |
--scope is repeatable; the union of the named scopes is used. When two scopes name
(or override) the same table, their seeds are unioned — a row matching either
scope is collected — and their per-table attributes merge most-inclusively: a cascade
runs if either scope opts in, and the limit is uncapped if either is, else the larger of
the two. More scopes therefore pull more (or equal), never less.
Need a one-off without editing the file? Use ad-hoc CLI seeds — see
run --seed:
clonedb run --seed public.accounts --where "id = 123" --follow-childrenVirtual foreign keys
Section titled “Virtual foreign keys”For relationships with no database constraint (polymorphic, app-enforced,
JSON-embedded) — CloneDB can’t discover these, so you declare them. The run engine
follows every virtual FK (upstream parents and downstream cascades), including
when discriminators and SQL-expression / JSON-embedded child columns.
There are two forms, and a config can mix them freely:
Terse map — the common case
Section titled “Terse map — the common case”A single-line entry per FK: "child.schema.table.col" = "parent[.col]". Omit the
parent column to target the parent’s primary key. This is what
config virtual-fks infer writes.
[virtual_foreign_keys]"public.comments.author_id" = "public.users" # parent column omitted → public.users.<pk>"public.orders.account_id" = "public.accounts.id" # explicit parent columnThe key is the child schema.table.column (a plain identifier); the value is the
parent schema.table (PK default) or schema.table.column.
Rules array — when, composite, or expression FKs
Section titled “Rules array — when, composite, or expression FKs”For anything the terse map can’t hold — a when discriminator, composite columns, or
a SQL-expression child column — use [[virtual_foreign_key_rules]]:
# Polymorphic: comments.commentable_id → posts.id WHEN commentable_type = 'post'[[virtual_foreign_key_rules]]child = { table = "public.comments", columns = ["commentable_id"] }parent = { table = "public.posts", columns = ["id"] }when = "commentable_type = 'post'" # discriminator predicate
# JSON-embedded reference: webhook_logs.payload->>'order_id' → orders.id.# The expression child column is followed: the referenced orders rows are pulled.[[virtual_foreign_key_rules]]child = { table = "public.webhook_logs", columns = ["(payload ->> 'order_id')::int"] }parent = { table = "public.orders", columns = ["id"] }| Key | Description |
|---|---|
child | The referencing side: { table = "schema.table", columns = [...] }. |
parent | The referenced side, same shape. |
when | Optional discriminator predicate (e.g. polymorphic commentable_type = 'post'). |
Child columns may be plain identifiers or SQL expressions (composite keys list multiple
columns); parent columns must be plain identifiers. validate checks plain-identifier
columns against the schema; SQL-expression columns are intentionally not column-checked.
Relationship overrides
Section titled “Relationship overrides”Stop a downstream cascade between two tables (rare). Here: a cascade that reaches
users doesn’t descend into events.
[[relationship_overrides]]child = "public.events"parent = "public.users"follow = falseAn override matches the table pair, so it covers every foreign key (real or virtual)
from child to parent. It only stops the cascade from parent to child. Upstream parents
are still pulled for referential integrity: an events row collected some other way still
brings its users row. Overrides can’t redirect an edge to another table.
| Key | Description |
|---|---|
child | Schema-qualified child table of the edge. |
parent | Schema-qualified parent table of the edge. |
follow | false stops the cascade from parent into child. true is the same as no override. |
See also
Section titled “See also”- CLI reference — the commands that read and write this file.
- Getting started — generate your first config and run it.
- Production safety — replicas, snapshot hold, session settings, and poolers.