Introspect
Read tables, columns, foreign keys, constraints, and cycles.
Documentation
Subveil is an open-source Go CLI that copies PostgreSQL into a smaller, anonymized copy. It runs inside your own network or CI, so raw data never leaves your infrastructure.
Three commands to a masked copy
$ subveil init$ subveil plan$ subveil pullSubveil introspects a PostgreSQL schema, plans a referentially intact subset, copies rows through an in-process masking pipeline, and writes the result to a target database.
Read tables, columns, foreign keys, constraints, and cycles.
Resolve roots, parents, children, load order, and row counts before writing.
Copy only the rows needed for a realistic development dataset.
Transform sensitive values while rows are in process memory.
source database │ ▼ COPY ──► mask in-process ──► COPY │ ▼ target database$ brew install subveilhq/tap/subveil$ brew upgrade subveilTo create a target schema automatically, install the PostgreSQL client tools as well:
$ brew install libpqDownload the release archive for your platform from your approved Subveil distribution channel. Archives are available for macOS, Linux, and Windows on AMD64 and ARM64. Verify the archive with checksums.txt before placing the binary on your PATH.
$ tar -xzf subveil_<version>_darwin_arm64.tar.gz$ sudo mv subveil_<version>_darwin_arm64/subveil /usr/local/bin/subveil$ subveil version$ tar -xzf subveil_<version>_<platform>_<arch>.tar.gz$ cd subveil_<version>_<platform>_<arch>$ make build$ make testThe binary is written to bin/subveil. Go 1.23 or newer is required.
Build with Go 1.23 or newer, provide three DSNs/secrets, create the target schema, then inspect and copy.
$ cd subveil$ make buildexport SUBVEIL_SOURCE_DSN="postgresql://readonly@localhost:5432/app"export SUBVEIL_TARGET_DSN="postgresql://developer@localhost:5432/app_staging"export SUBVEIL_SEED="replace-with-a-secret-seed"$ pg_dump --schema-only --no-owner "$SUBVEIL_SOURCE_DSN" | psql "$SUBVEIL_TARGET_DSN"$ subveil init$ subveil plan$ subveil pull --dry-run$ subveil pullCloud mode is enabled when a project ID is configured and CLI credentials are available. It exchanges schema metadata, policy configuration, and signed receipt information, not row data.
$ subveil pull --config subveil.yaml --cloud$ subveil pull --config subveil.yaml --no-cloudUse --cloud to require policy sync and audit submission. Use --no-cloud to disable cloud communication. --require-audit fails the pull when policy retrieval or receipt submission fails.
--create-schemaWhen available, --create-schema uses pg_dump to create the target schema inside the pull transaction. The target must not already contain tables and the source and target must be different databases.
$ subveil pull --config subveil.yaml --create-schema# Or create it manually:$ pg_dump --schema-only --no-owner "$SUBVEIL_SOURCE_DSN" | psql "$SUBVEIL_TARGET_DSN"subveil initIntrospect the source and write a generated configuration.
$ subveil init --dsn "$SUBVEIL_SOURCE_DSN" --schemas public,health --out subveil.yamlwrote subveil.yamlintrospected 64 tables · 112 foreign keyssubveil planValidate the graph and show the planned load levels without writing rows.
$ subveil plan --dsn "$SUBVEIL_SOURCE_DSN"level 0 public.tenants (1,204 rows)level 1 public.users (8,420 rows), public.pets (42,891 rows)level 2 public.appointments (109,230 rows)cycle public.users ↔ public.profiles · pass 2 requiredsubveil pullCopy the planned rows into the target in one transaction.
$ subveil pull --config subveil.yaml --dry-runpass 1 public.tenants 1,204 rowspass 1 public.users 8,420 rowspass 2 public.profiles 8,420 rowssummary 18 tables · 142,330 rows · masked 17 columns · target unchanged$ subveil pull --config subveil.yaml --truncatepass 1 ...pass 2 ...complete 142,330 rowsCreate the target schema as part of the pull and copy tables in parallel:
$ subveil pull --config subveil.yaml --create-schema --jobs 4| Command / flag | Type | Default | Description |
|---|---|---|---|
| init [--dsn] | string | — | Source DSN; defaults to SUBVEIL_SOURCE_DSN. |
| init [--schemas a,b] | string | — | Comma-separated schemas to inspect. |
| init [--out subveil.yaml] | path | subveil.yaml | Generated configuration path. |
| init [--force] | boolean | — | Overwrite an existing configuration. |
| plan [--dsn] [--schemas a,b] | string | — | Inspect and display the planned graph. |
| pull --configrequired | path | — | Configuration file to execute. |
| pull [--source] [--target] [--schemas] | string | — | Override source, target, or schema selection. |
| pull [--truncate] [--dry-run] | boolean | — | Empty the target before loading, or plan without writing. |
| pull [--create-schema] | boolean | — | Create the target schema before loading rows. |
| pull [--jobs N] | integer | 1 | Number of tables copied in parallel within a load level. |
subveil loginAuthenticate this machine with the Subveil control plane. The CLI prints a code and opens /device in your browser; approve the request there and the token is stored locally. It is only needed for control-plane features such as policy sync and audit receipts.
$ subveil loginOpen https://subveil.dev/device and enter code ABCD-1234waiting for approval…logged insubveil logoutDelete locally stored credentials. This does not revoke an API key.
$ subveil logoutremoved ~/.config/subveil/credentials.json| Name | Type | Default | Description |
|---|---|---|---|
| versionrequired | integer | — | Configuration format version. |
| source.dsn_envrequired | string | — | Environment variable containing the read-only source DSN. |
| target.dsn_envrequired | string | — | Environment variable containing the target DSN. |
| masking.seed_envrequired | string | — | Environment variable containing the HMAC masking seed. |
| subset.roots | list | — | Root rows. Each root uses exactly one of where or scale. |
| subset.full_tables | list | — | Lookup tables copied in full. |
| subset.exclude_tables | list | — | Tables excluded from the copy. |
| tables.<schema.table>.columns | map | — | Column transformer definitions. |
version: 1source: dsn_env: SUBVEIL_SOURCE_DSNtarget: dsn_env: SUBVEIL_TARGET_DSNmasking: seed_env: SUBVEIL_SEEDsubset: roots: - table: public.tenants where: "id = 'acme'" full_tables: - public.currencies exclude_tables: - public.audit_eventstables: public.customers: columns: email: { transformer: email, domain: example.test } phone: { transformer: phone } profile: { transformer: jsonb, paths: ["$.address.street", "$.date_of_birth"] } public.patients: columns: name: { transformer: name } medical_record: { transformer: redact, value: "[REDACTED]" }| Transformer | Type | Default | Description |
|---|---|---|---|
| domain | — | Deterministic email; optional domain replaces the original domain. | |
| name | — | — | Deterministic realistic name. Not unique-safe. |
| phone | — | — | Deterministic phone-shaped value. |
| address | — | — | Deterministic address. Not unique-safe. |
| uuid | — | — | Deterministic UUID with a 48-bit token. |
| redact | value | — | Replace with the configured value. |
| "null" | — | — | Write quoted null to avoid YAML treating it as unset. Alias: nullify. |
| custom_sql | sql | — | Evaluate the configured SQL expression. |
| jsonb | paths | — | Mask values at the listed JSONPath expressions. |
$.a.b nested key[n] array index[*] every array element.* every child key['key'] key with punctuation..key recursive key searchEach transformer section should be tested against representative values before a production-like refresh. For nested PII, use jsonb with explicit paths.
Subveil detects primary keys, unique constraints, and unique indexes. email and uuid are always unique-safe. name, address, and phone use unique-aware suffixes when required. Avoid redact on unique columns because a constant value cannot preserve uniqueness.
Ava Smith #3fa91c07Oak St, Springfield, CA 12345 #3fa91c07+1-555-012-3456 x48213907For each supported value, Subveil computes HMAC-SHA256(seed, kind ‖ exact input). The same input produces the same output in every table and run, so joins on masked columns still match.
same seed + same kind + same exact input → same outputchanging the seed → changes every masked outputNULL and empty strings → left unchangedemail and uuid → 48-bit token; input is case-sensitiveroots ──down──► children ▲ │ └────── up ───────┘roots select the slice; children follow their foreign keys;parents are pulled when needed, but their other children are not.Use full_tables for lookup data such as currencies. Use exclude_tables for data that should never be copied. With no roots, the default is a full copy.
Subveil detects strongly connected components with Tarjan's algorithm. It tries these strategies in order:
null_then_update: insert a nullable edge, then back-fill it in pass 2.defer_constraint: defer a DEFERRABLE constraint inside the target transaction.replica_role: make the target constraint DEFERRABLE inside the transaction, then restore it.pass 1 users created_by = NULLpass 1 profiles user_id = users.idpass 2 users created_by = profiles.idcommit cycle resolved; all foreign keys valid--truncate is explicit.name: Refresh stagingon: schedule: - cron: "0 2 * * *"jobs: refresh: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - run: make build - run: subveil pull --config subveil.yaml --truncatesubset: roots: - table: public.tenants scale: 0.05 full_tables: [public.currencies]tables: public.users: columns: profile: { transformer: jsonb, paths: ["$.address.street", "$.dob"] }For unique columns, choose a unique-safe transformer and review the generated configuration before the first pull.
| Error | Type | Default | Description |
|---|---|---|---|
| missing table | schema | — | Create the target schema first with pg_dump --schema-only --no-owner ... | psql ... |
| target tables are not empty | target | — | Use --truncate only when deleting target rows is intentional. |
| duplicate key on a masked column | masking | — | Use a unique-safe transformer such as email or uuid. |
| no masking seed | environment | — | Export SUBVEIL_SEED before running init, plan, or pull. |
| NOT NULL cycle warning | foreign key | — | Use a nullable edge or a DEFERRABLE target constraint for pass 2. |
| transformer: null parsed as unset | yaml | — | Write the transformer as quoted "null"; nullify is an alias. |