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.
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:
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" .
-- 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:
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:
| Kind | Source | Layout |
|---|---|---|
rds_snapshot | An 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. |
csv | A directory of CSV files. | A file per table, named <table>.csv, <schema>.<table>.csv or <database>.<schema>.<table>.csv. |
synthetic | Rows 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:
| Attribute | Description |
|---|---|
src | An object-store URL, s3://, gs://, or azblob://, or a local directory, relative to the test file. |
include | Glob patterns of the dataset tables to load, matched against <table> and <schema>.<table>. All tables by default. |
exclude | Glob patterns of the dataset tables to skip. |
sampler | A sampler block, to load a sample of each table instead of all its rows. |
masks | A list of mask blocks, applied to the rows before they are written. |
batch | The 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 theregionandprofilequery parameters. Setprofile, or theAWS_PROFILEenvironment variable, to read with a profile of the AWS configuration, an SSO one included. Without credentials, the load fails withno 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 theAZURE_STORAGE_ACCOUNTenvironment variable or thestorage_accountquery 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.usersandarchive.usersloaded into a single schema. Select one of them withincludeorexclude.
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:
id,slug,display_name,tier
1000,qa-team,QA Team,enterprise
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:
\NisNULL. An empty value isNULLtoo, 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\Nfor aNULLin these columns.- A boolean is
true,t,yes,y, or1, orfalse,f,no,n, or0, in any case. - Integers and floats are parsed by Atlas. A binary value is written as the bytes of its text, so
\x0102loads 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:
ALTER TABLE billing.users ADD COLUMN email_domain text;
UPDATE billing.users SET email_domain = split_part(email, '@', 2);
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" .
-- PASS: 20261013090000 (11.39s)
PASS
The backfill of the new version ran over the users of all 250 tenants of the export.
- 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 tofalse, 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 DEFERREDone.
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:
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:
| Method | Rows taken of each table |
|---|---|
head | The first rows, in the order of the snapshot. |
tail | The last rows, in the order of the snapshot. |
random | Rows 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. |
filter | The rows matching its column blocks. See Sampling by a filter. |
composite | The 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:
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:
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:
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:
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:
-- 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:
columnsnames 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 eachuseexpanded 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.
HASHrequires asalt: 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:
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
}
}
-- 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:
| Reason | Cause | Fix |
|---|---|---|
HASH requires a salt: without it, a digest is reversed by hashing guesses | A HASH mask has no salt. | Set salt. |
masked values are free text, and the column holds none | The 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 type | The 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 from | The 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 column | A foreign key references the column. | Leave key columns unmasked. |
<METHOD> may give rows the same value, and the column is unique | The 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 body | Loaded 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.emaildomain ofbilling.users.emailrejects<EMAIL>. - A number is masked only when it is an entity as a whole, such as a card number stored in a SQLite
integercolumn. - An entity mask with
HASHstill requires asalt. - 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:
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:
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" .
-- 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:
slugfollows thetenants_slug_shapecheck, which generated text would not pass.due_onreads theissued_onvalue of its row, soinvoices_due_after_issueholds.stateisopenin about one invoice in twenty, instead of thedraftdefault 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
tableblock 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
columnblock 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 acolumnblock 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 sameseed, - and the HCL functions, such as
format,timeadd, andformatdate.
| Function | Returns |
|---|---|
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
columnblock writes. A row that breaks one fails the load with the database error, so set the columns they cover, as the example does. Ananalytics.eventsrow needs anatwithin 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 asvalue = row.index + 1001onid. -
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
NULLin every row. The load fails if none can beNULL, or if acolumnblock sets it. A row of a table referencing itself references an earlier row, or itself for the first row.