Skip to main content

Test Data Management: Seed Tests with Production or Synthetic Data

A dataset block registers rows in a test file: an Amazon RDS snapshot exported to S3, a directory of CSV files, or rows generated from the schema. The dataset command of a schema test or a migration test loads them into the test database, so the assertions and migrations that follow run over production-like rows instead of an empty schema.

A test rarely needs the whole export. Use a sampler to load a slice of each table, with its foreign keys intact, and masks to rewrite sensitive columns, such as emails and names, before they are loaded. A synthetic dataset needs no export, and generates rows at any size.

Preview Feature

Datasets are in preview and open to a subset of Teams and Enterprise customers. To get access, reach out through your Atlas support channel or message us on Intercom.

Seeding a Test​

The examples on this page use a PostgreSQL database with two schemas: billing, which holds tenants, users, plans, subscriptions, invoices, invoice lines, payments, and support tickets, and analytics, which holds daily usage and an events log partitioned by quarter. Most of them load an RDS snapshot export of it, and the synthetic examples generate its rows instead.

The project holds the schema of the exported database and a test file:

.
├── schema.sql
└── schema.test.hcl

The test file registers the export as a dataset, and the test case loads it before its assertions:

schema.test.hcl
dataset "rds_snapshot" "prod" {
src = "s3://backups/exports/prod-2026-10-13/appdb?region=us-east-1"
exclude = ["analytics.events*"]
}

test "schema" "invoices" {
dataset {
use = dataset.rds_snapshot.prod
}
exec {
sql = "SELECT count(*) FROM billing.invoice_lines l JOIN billing.invoices i ON i.id = l.invoice_id"
output = "9000"
}
# The keys of the snapshot are kept, and new rows get keys after them.
exec {
sql = "INSERT INTO billing.tenants (slug, display_name) VALUES ('after-load', 'After Load') RETURNING id"
output = "251"
}
}

Atlas reads the export with your AWS credentials. Run the test with a profile that can read the bucket, on a dev database:

export AWS_PROFILE=exports-reader
atlas schema test --dev-url "docker://postgres/18/dev" --url "file://schema.sql" .
Test Output
-- PASS: invoices (5.38s)
PASS

-- PASS: invoices means the tables were loaded, each after the tables it references, and the next generated key followed the loaded ones. The exclude pattern skips the events log and its partitions, which hold most rows of the export.

Defining a Dataset​

A dataset is a top-level block of a test file, labeled with its kind and a name:

schema.test.hcl
dataset "rds_snapshot" "trimmed" {
src = "s3://backups/exports/prod-2026-10-13/appdb?region=us-east-1"
include = ["billing.*", "analytics.*"]
exclude = ["analytics.events*"]
}

This dataset loads the billing and analytics schemas of the export, without the events table and its partitions. The kind sets where the rows come from, and the file kinds read the files under src, at any depth:

KindSourceLayout
rds_snapshotAn Amazon RDS snapshot exported to Amazon S3.Parquet files in a directory per table, named <schema>.<table> or <database>.<schema>.<table>, as the export writes them.
csvA directory of CSV files.A file per table, named <table>.csv, <schema>.<table>.csv or <database>.<schema>.<table>.csv.
syntheticRows generated from the schema.No files: table blocks name the tables to generate. See Generating Synthetic Data.

An export task writes its files under <prefix>/<task-id>/<database>, a directory per database of the instance. Point src at one of them, as prod-2026-10-13/appdb above, so that equally named tables of two databases do not load into the same table.

For the rds_snapshot and csv kinds, src is required, and the other attributes are optional:

AttributeDescription
srcAn object-store URL, s3://, gs://, or azblob://, or a local directory, relative to the test file.
includeGlob patterns of the dataset tables to load, matched against <table> and <schema>.<table>. All tables by default.
excludeGlob patterns of the dataset tables to skip.
samplerA sampler block, to load a sample of each table instead of all its rows.
masksA list of mask blocks, applied to the rows before they are written.
batchThe maximum number of rows per INSERT statement. Defaults to 1000.

An object-store URL takes its credentials from the provider's default chain: environment variables, a shared configuration file, or the role of the machine running Atlas.

  • An s3:// URL accepts the region and profile query parameters. Set profile, or the AWS_PROFILE environment variable, to read with a profile of the AWS configuration, an SSO one included. Without credentials, the load fails with no EC2 IMDS role found.
  • An RDS export is encrypted with the KMS key of its export task, so the credentials also need permission to decrypt with that key.
  • An azblob:// URL names its storage account in the AZURE_STORAGE_ACCOUNT environment variable or the storage_account query parameter.

Matching tables​

A dataset table loads into the database table with the same <schema>.<table> name. When the database holds a single schema, as with a dev URL that names one (search_path=public), the table name alone matches, since the exported schema often has another name.

  • A dataset table with no matching table in the database is skipped.
  • A dataset column with no column of the same name in its table, case included, is ignored, and a table column missing from the dataset gets its default value.
  • A generated column is computed by the database, and its dataset values are ignored.
  • Two dataset tables matching the same database table fail the load, for example public.users and archive.users loaded into a single schema. Select one of them with include or exclude.

CSV files​

A csv dataset reads a directory of CSV files, for rows the export does not hold. A test case can load it after the export, as this one does to add a tenant for the tests:

fixtures/billing.tenants.csv
id,slug,display_name,tier
1000,qa-team,QA Team,enterprise
schema.test.hcl
dataset "csv" "fixtures" {
src = "fixtures"
}

test "schema" "tenants" {
dataset {
use = dataset.rds_snapshot.prod
}
dataset {
use = dataset.csv.fixtures
}
exec {
sql = "SELECT count(*) FROM billing.tenants"
output = "251"
}
}

The first line of a CSV file names its columns, and Atlas parses each value by the type of the column it loads into:

  • \N is NULL. An empty value is NULL too, except in text, enum, and JSON columns, where it stays an empty string. An empty string is not valid JSON, nor a valid enum value unless it is one of the labels, so write \N for a NULL in these columns.
  • A boolean is true, t, yes, y, or 1, or false, f, no, n, or 0, in any case.
  • Integers and floats are parsed by Atlas. A binary value is written as the bytes of its text, so \x0102 loads as six bytes, not two.
  • Any other value, such as a date, a timestamp, a decimal, or a UUID, is passed to the database as text, which casts it to the column type.

Loading a Dataset​

The dataset command loads a dataset into the test database, at the point of the test case where it appears. Its use attribute references the dataset block by kind and name:

dataset {
use = dataset.rds_snapshot.prod
}

In a test "schema" case, the dataset loads into the tested schema. In a test "migrate" case, the command must follow a migrate command, and loads into the version that command migrated to. The migrations after it then run over the loaded rows. Given a migration directory whose versions up to 20261012090000 create the schema of the export, and a new version that backfills a column:

migrations/20261013090000_email_domain.sql
ALTER TABLE billing.users ADD COLUMN email_domain text;
UPDATE billing.users SET email_domain = split_part(email, '@', 2);
migrate.test.hcl
dataset "rds_snapshot" "users" {
src = "s3://backups/exports/prod-2026-10-13/appdb?region=us-east-1"
include = ["billing.tenants", "billing.users"]
}

test "migrate" "20261013090000" {
migrate {
to = "20261012090000"
}
dataset {
use = dataset.rds_snapshot.users
}
migrate {
to = "20261013090000"
}
exec {
sql = "SELECT count(DISTINCT email_domain) FROM billing.users"
output = "250"
}
}

Run the test:

atlas migrate test --dev-url "docker://postgres/18/dev" .
Test Output
-- PASS: 20261013090000 (11.39s)
PASS

The backfill of the new version ran over the users of all 250 tenants of the export.

note
  • A failed load leaves no rows behind, and fails the test case.
  • Primary-key values are kept, and rows inserted after the load get keys after the loaded ones.
  • To skip a partitioned table, exclude its partitions too, as analytics.events* does above.
  • Every test case that uses a dataset loads it again.
  • A case that sets parallel, even to false, cannot load a dataset.
  • A table referencing itself loads up to 100,000 rows, counted after sampling.
  • Tables that reference each other in a cycle need foreign keys that are checked at commit, such as a PostgreSQL DEFERRABLE INITIALLY DEFERRED one.

Sampling​

A sampler block loads a sample of each table instead of all its rows. Declare it at the top level of a test file, labeled with its method and a name, and reference it from a dataset:

schema.test.hcl
sampler "head" "small" {
rows = 10
}

dataset "rds_snapshot" "sample" {
src = "s3://backups/exports/prod-2026-10-13/appdb?region=us-east-1"
include = ["billing.*"]
sampler = sampler.head.small
}

test "schema" "sample" {
dataset {
use = dataset.rds_snapshot.sample
}
exec {
sql = "SELECT count(*) FROM billing.invoices"
output = "19"
}
exec {
sql = "SELECT count(*) FROM billing.payments p LEFT JOIN billing.invoices i ON i.id = p.invoice_id WHERE i.id IS NULL"
output = "0"
}
}

A sample keeps every row its rows reference, in the same table or another loaded one, so no foreign key is left dangling. A table can then hold more rows than rows: here, billing.invoices holds its first 10 rows and 9 more, the invoices of the first 10 payments that are not among them.

The head, tail, and random samplers require rows, the number of rows taken of each table:

MethodRows taken of each table
headThe first rows, in the order of the snapshot.
tailThe last rows, in the order of the snapshot.
randomRows drawn at random. A run over the same snapshot draws the same rows, a larger sample keeps the rows of a smaller one, and seed selects another draw.
filterThe rows matching its column blocks. See Sampling by a filter.
compositeThe rows of the sampler that its first matching table block names. See Sampling tables differently.

The tail and random samplers count the rows of each table before sampling it: from the metadata of its files in an RDS export, and by reading them in CSV files.

A head or tail sample reads about as many rows as it takes. A random sample spreads its rows over the whole table, so it reads nearly all of it: on a table of 2,000,000 rows in an RDS export in S3, 10,000 random rows took about as long as loading every row, while 10,000 rows of head or tail took seconds. Use head or tail on large tables unless the sample must be random, and give the large tables their own sampler with a composite one.

Set related = true on a head, tail, random, or filter sampler to also load the rows referencing the sampled ones, one level down: these rows bring the rows they reference, but not the rows referencing them. The tables referencing a related sample are read whole.

Sampling by a filter​

A filter sampler takes the rows that match its column blocks, instead of a number of rows. This sampler takes one tenant, and with related = true, the rows referencing it:

schema.test.hcl
sampler "filter" "one_customer" {
column "billing.tenants.id" {
eq = 42
}
related = true
}

A column block compares the values of its column with one of the eq, ne, in, lt, le, gt and ge operators, or with match, a regular expression. The and, or, and not blocks combine conditions. A NULL value matches no operator.

Sampling tables differently​

A composite sampler samples each table by the first table block whose match patterns match it, in the order the blocks appear. A table that no block matches loads all its rows:

schema.test.hcl
sampler "random" "large" {
rows = 10000
}

sampler "head" "first" {
rows = 100
}

sampler "composite" "prod" {
table {
match = ["analytics.events"]
use = sampler.random.large
}
table {
match = ["billing.plans"]
all = true
}
table {
match = ["*"]
use = sampler.head.first
}
}

This sampler draws 10,000 random rows of the events log, keeps every plan, and takes the first 100 rows of every other table. Each table block sets either use, a head, tail, or random sampler, or all = true to load every row of the matching tables.

Masking​

Top-level mask blocks rewrite the values of sensitive columns before a dataset writes them. A dataset applies the masks it lists in masks. Mask blocks follow the grammar of Data Scripts masks: the REDACT, PARTIAL, HASH, and REPLACE methods, use to group masks, and entities to mask what values contain.

This example hashes the names of users, keeps the first letter of customer names, and masks the emails, phone numbers, card numbers, and IP addresses customers wrote in their support tickets:

schema.test.hcl
variable "mask_salt" {
type = string
}

mask "names" {
columns = ["billing.users.full_name"]
method = HASH
salt = var.mask_salt
}

mask "customers" {
columns = ["billing.tenants.display_name"]
method = PARTIAL
keep_left = 1
}

mask "tickets" {
columns = ["billing.support_tickets.body"]
entities = [ALL]
}

mask "pii" {
use = [mask.names, mask.customers, mask.tickets]
}

dataset "rds_snapshot" "masked" {
src = "s3://backups/exports/prod-2026-10-13/appdb?region=us-east-1"
include = ["billing.*"]
masks = [mask.pii]
}

test "schema" "masked" {
dataset {
use = dataset.rds_snapshot.masked
}
# first_name is computed from full_name, so it follows the digest.
exec {
sql = "SELECT count(*) FROM billing.users WHERE full_name ~ '^[0-9a-f]{64}$' AND first_name = full_name"
output = "5000"
}
exec {
sql = "SELECT display_name FROM billing.tenants WHERE id = 1"
output = "G*************"
}
exec {
sql = "SELECT body FROM billing.support_tickets WHERE id = 1"
output = "Login fails for <EMAIL> since this morning."
}
}

The salt is a secret. The project file reads it from the MASK_SALT environment variable, and passes it to the test file as an input variable:

atlas.hcl
variable "mask_salt" {
type = string
default = getenv("MASK_SALT")
}

env "local" {
src = "file://schema.sql"
dev = "docker://postgres/18/dev"
test {
schema {
src = ["schema.test.hcl"]
vars = {
mask_salt = var.mask_salt
}
}
}
}

Set MASK_SALT before the run. Unset, it leaves the salt empty, and the masked case fails with HASH requires a salt.

atlas schema test --env local

With the cases of the previous sections in the same test file, the run prints:

Test Output
-- PASS: invoices (42.71s)
-- PASS: tenants (41.85s)
-- PASS: sample (59.90s)
-- PASS: masked (35.90s)
PASS

-- PASS: masked means every name was replaced by its 64-character digest, every customer name kept only its first letter, and the email in the first ticket was replaced by <EMAIL>.

For a dataset, masks select and apply as follows:

  • columns names columns as <column>, <table>.<column>, or <schema>.<table>.<column>, each part a glob. A name without a glob must match a column of the database, so a typo fails the load instead of leaving a column unmasked.
  • The first mask selecting a column masks it, in the order of masks, with each use expanded in place.
  • A glob skips the columns its mask cannot change, and leaves them to the next mask: generated columns, and columns whose type cannot hold the masked values, such as numbers, dates, or network addresses. A name takes such a column anyway, and the load fails with the reason, as a skipped column would load unmasked. A glob does not skip a key, or a column too short for the masked values: these fail the load too.
  • HASH requires a salt: without one, a digest is reversed by hashing guesses. With a fixed salt, a value gets the same digest on every load and in every table, so masked values stay unique in a unique column and joinable across tables.

Columns a mask cannot change​

A mask without entities applies to every value of the columns it selects. Before reading any row, Atlas checks that each of these columns can hold the masked values. For example, a REDACT mask on the unique billing.invoices.number column fails the test case:

schema.test.hcl
mask "invoice_numbers" {
columns = ["billing.invoices.number"]
method = REDACT
}

dataset "rds_snapshot" "redacted" {
src = "s3://backups/exports/prod-2026-10-13/appdb?region=us-east-1"
include = ["billing.*"]
masks = [mask.invoice_numbers]
}

test "schema" "redacted" {
dataset {
use = dataset.rds_snapshot.redacted
}
}
Test Output
-- FAIL: redacted (2.33s)
schema.test.hcl:14:
Error: sqldataset: mask column "invoices.number": REDACT may give rows the same value, and the column is unique
FAIL

Troubleshooting​

The error names the column, then the reason. Find the reason below for its cause and fix:

ReasonCauseFix
HASH requires a salt: without it, a digest is reversed by hashing guessesA HASH mask has no salt.Set salt.
masked values are free text, and the column holds noneThe column holds numbers, dates, booleans, binary data, UUIDs, or geometries. Or it holds enum labels, JSON, arrays, or composite values, and the method is REDACT, PARTIAL, or HASH.Mask text columns. Mask an enum, JSON, array, or composite column with an entity mask or REPLACE.
masked values break the format of the column typeThe column holds network addresses, such as a PostgreSQL inet or a ClickHouse IPv4 column, and the method is not REPLACE.Use REPLACE, whose values the database checks.
the database computes the column, so mask the columns it is computed fromThe column is generated.Mask the columns it is computed from.
masked values break foreign key "<name>"The column is part of a foreign key.Leave key columns unmasked.
masked values break the foreign keys referencing the columnA foreign key references the column.Leave key columns unmasked.
<METHOD> may give rows the same value, and the column is uniqueThe column is in a primary key or a unique index, including an expression index over it, and the method is not HASH.Use HASH, which keeps distinct values distinct.
masked values have <n> <unit>, and the column holds <size>A REDACT token or a HASH digest is longer than the column, counted in the unit its database counts: characters, bytes for ClickHouse FixedString, SQL Server varchar, and Oracle VARCHAR2, or byte-pairs for SQL Server nvarchar.Set a shorter token, or use PARTIAL.

Atlas knows the types specific to each database: a PostgreSQL domain counts as the type it is based on, citext as text, and inet as a network address. A type it cannot place is left to the database, such as xml, an extension type, or a SQLite column of a type other than text or BLOB, as SQLite keeps any value outside a STRICT table. The check of a domain is left to the database too. A glob does not skip these columns, as they may hold text, and the database rejects a masked value that breaks them: the billing.email domain of billing.users.email checks the format of an email, so it rejects a digest or <EMAIL> alike, and fails the load.

Masking entities​

A mask with entities masks the entities found in a value, such as email addresses and phone numbers, and keeps the rest of the value, as the tickets mask of the Masking example does with the free text of support tickets:

Ticket bodyLoaded as
Login fails for ada.lovelace@globex-001.example.com since this morning.Login fails for <EMAIL> since this morning.
Our card 4111 1111 1111 1111 was charged twice this month.Our card <CREDIT_CARD> was charged twice this month.
Requests from 10.0.3.21 are throttled.Requests from <IP> are throttled.
The export page times out after 30 seconds.The export page times out after 30 seconds.
Please call me back at +1 (415) 555-0005 about the renewal.Please call me back at <PHONE> about the renewal.

The entity list and the way each method masks an entity are the same as for Data Scripts.

  • An entity mask scans text, enum, JSON, array, and composite columns. A glob of one skips generated columns, and the columns of numbers, dates and times, booleans, binary data, UUIDs, geometries, and network addresses, and a name refuses them.
  • An entity mask also scans the types left to the database, where a masked value that breaks the type fails the load: the billing.email domain of billing.users.email rejects <EMAIL>.
  • A number is masked only when it is an entity as a whole, such as a card number stored in a SQLite integer column.
  • An entity mask with HASH still requires a salt.
  • Atlas does not check an entity mask against keys and column sizes, as it changes only the values holding entities. The database checks the masked values: one that breaks a key fails the load, and so does one longer than its column on a database that enforces column sizes. SQLite does not.
  • Entities are recognized by their format and checksum, not by meaning. Names and street addresses are not entities, and an entity written in an unusual format stays unmasked. Mask a column holding only sensitive values with a whole-value mask.

Generating Synthetic Data​

A synthetic dataset generates the rows of the tables it names from the schema, instead of reading them from files. Use it to test a change on rows at production scale without copying production data, such as the plan of a query after a migration. The dataset command loads it like any other dataset.

Given the migration directory of Loading a Dataset, a new version adds an index for the query that lists the open invoices of a tenant:

migrations/20261020090000_invoices_tenant_state.sql
CREATE INDEX invoices_tenant_state ON billing.invoices (tenant_id, state);

The test generates 50 tenants and 200,000 invoices, applies the new version, and checks that the query reads the invoices through the index:

migrate.test.hcl
dataset "synthetic" "invoices" {
table "billing.tenants" {
rows = 50
column "slug" {
value = format("tenant-%d", row.index)
}
}
table "billing.invoices" {
rows = 200000
column "due_on" {
value = random::time(row.issued_on, "2026-12-31")
}
column "state" {
value = random::int(1, 100) <= 5 ? "open" : "paid"
}
}
}

test "migrate" "20261020090000" {
migrate {
to = "20261013090000"
}
dataset {
use = dataset.synthetic.invoices
}
migrate {
to = "20261020090000"
}
exec {
sql = "ANALYZE billing.invoices"
}
exec {
sql = "EXPLAIN (COSTS OFF) SELECT id FROM billing.invoices WHERE tenant_id = 7 AND state = 'open'"
output = <<-PLAN
Bitmap Heap Scan on invoices
Recheck Cond: ((tenant_id = 7) AND (state = 'open'::billing.invoice_state))
-> Bitmap Index Scan on invoices_tenant_state
Index Cond: ((tenant_id = 7) AND (state = 'open'::billing.invoice_state))
PLAN
}
}

The test reads no export, so it runs without AWS credentials:

atlas migrate test --dev-url "docker://postgres/18/dev" .
Test Output
-- PASS: 20261020090000 (3.26s)
PASS

-- PASS: 20261020090000 means PostgreSQL plans the query with the new index over the generated rows. Before the version, the same query reads the whole table with a Parallel Seq Scan. A hand-written seed cannot test this: on 10 invoices, PostgreSQL scans the table even with the index. ANALYZE updates the statistics the planner reads, so it plans for the loaded rows.

Each column block sets values that the schema checks, or that the plan depends on:

  • slug follows the tenants_slug_shape check, which generated text would not pass.
  • due_on reads the issued_on value of its row, so invoices_due_after_issue holds.
  • state is open in about one invoice in twenty, instead of the draft default in every row. A plan depends on how many rows a filter matches, so set the columns a query filters on as production holds them.

A synthetic dataset requires one or more table blocks, and each requires rows, the number of rows to generate. The dataset optionally takes seed, which selects other generated values, and batch, but none of src, include, exclude, sampler, and masks.

  • A table block names a table as <schema>.<table>, or as <table> when the database holds a single schema. Unlike in a file dataset, a name that matches no table fails the load.
  • A table referencing another one needs it generated too, unless its foreign key is nullable or a column block sets it.

Generated values​

A column without a column block gets a value that fits its type and its constraints:

  • Primary keys and unique columns get distinct values.
  • Foreign keys reference generated rows of the referenced table.
  • Generated columns and columns with a default are left to the database.
  • Other columns get a value of their type, such as a number, a text, a date, or one of the labels of an enum.

The same seed, 0 by default, generates the same rows on every run. To control the values of a column, set them with a column block.

A column of a type Atlas cannot generate, such as a PostgreSQL domain, range, array, or composite type, is NULL when it is nullable, and fails the load otherwise with cannot generate values of type <type>. Set its values with a column block.

Setting column values​

A column block sets the values of a column, in place of the generated values or its default. Its value is evaluated for each row, and can read:

  • row.index, the row counted from 0,
  • row.<column>, the value Atlas generates or a column block sets for another column of the row, but not one the database fills, by a default or as a generated column,
  • the random:: functions below, which draw the same values for the same seed,
  • and the HCL functions, such as format, timeadd, and formatdate.
FunctionReturns
random::int(min, max)An integer from min to max, both included.
random::float(min, max)A number from min, included, to max, excluded.
random::string(length)A string of length letters and digits.
random::time(from, to)A time from from to to, as YYYY-MM-DD hh:mm:ss in UTC. Each bound is a date, a date and time, or an RFC 3339 time.
random::uuid()A random UUID.
random::bool()true or false.
random::element(list)An element of the list.

A null value writes NULL. Added to the billing.tenants block, this block sets deleted_at in about one tenant in ten, and leaves it NULL in the others:

column "deleted_at" {
value = random::int(1, 10) == 1 ? random::time("2026-01-01", "2026-06-30") : null
}

A column block can set a foreign key of one column, with keys of generated rows of the referenced table, such as random::int(1, 50) for the tenant_id of 50 tenants. It cannot set a column of a foreign key of several columns, or a generated column.

Limits of generated rows​

  • Atlas does not read checks, exclusion constraints, partition bounds, or the checks of the domains a column block writes. A row that breaks one fails the load with the database error, so set the columns they cover, as the example does. An analytics.events row needs an at within the 2026 partitions.

  • A key made of a foreign key and columns Atlas cannot keep distinct, such as a date, holds at most one row per referenced row. analytics.daily_usage, keyed by (tenant_id, day), takes at most 50 rows from the 50 tenants unless its days are set:

    table "analytics.daily_usage" {
    rows = 1000
    column "day" {
    value = formatdate("YYYY-MM-DD", timeadd("2026-01-01T00:00:00Z", format("%dh", 24 * row.index)))
    }
    }

    Without the block, the load fails with 1000 rows exceed the 50 distinct values of key (tenant_id, day).

  • Generated keys count from 1, whatever rows the table already holds. Loaded after the export of Seeding a Test, the generated tenants collide with its 250 tenants, and the load fails with duplicate key value violates unique constraint "tenants_pkey". Set the key past the loaded ones, such as value = row.index + 1001 on id.

  • Rows agree only along their foreign keys. A support ticket and its user can belong to different tenants, so assert over keys, or set the values that must agree.

  • In a cycle of foreign keys between tables, one nullable foreign key of the cycle is NULL in every row. The load fails if none can be NULL, or if a column block sets it. A row of a table referencing itself references an earlier row, or itself for the first row.

Next Steps​