Skip to main content
This guide moves an existing users table into Databunker Pro. The whole user record goes into the vault; your application database keeps nothing but a token. For the phases and mechanics shared by every migration, see the migration overview. Rather than altering users in place, you build a parallel newusers table, backfill it while the application keeps running, verify it, and then swap the two tables in a single transaction. Nothing is destructive until that swap, and the swap itself is reversible.
Timing comes from the performance benchmarks: bulk writes sustain roughly 5,800 records/sec on a single instance, so 10 M records take about 28 minutes and 100 M around 2.3 hours. At tens of millions of rows the database is the limit, not Databunker Pro — see the sizing guide before starting a large backfill.

Before you start

  • A running Databunker Pro instance and a root or admin access token.
  • A license whose record cap covers your row count. Trial mode stops at 1,000 records, and the cap is checked when a bulk request begins — see when the record cap is reached.
  • The Python SDK: pip install databunkerpro psycopg2-binary
  • A backup of the users table.

Step 1: Create the newusers table

Two columns: your existing primary key, and the token Databunker Pro returns.
Everything else in users goes into the encrypted vault profile — not just the personal data, but every application field as well: created_at, status, plan, and the rest. A 120-field profile is entirely normal; that is what the benchmarks were run against. Keeping the record whole is the point. Split it across two stores and you are back to reconciling two sources of truth for the same user, with some attributes protected and some not.

Step 2: Decide how tokens map back to rows

UserCreateBulk returns the original profile alongside each token, so the mapping never depends on response ordering. To use that, carry your primary key inside the profile. The custom field is the best place for it. It is one of the four indexed fields and is unique within a tenant, which buys you two things:
  • A re-run cannot create duplicate tokens. A second attempt for the same id is rejected with Duplicate custom value instead of silently minting a second token.
  • You can look a user up by legacy id later with UserGet using mode=custom.
The trade-off is that custom is then spoken for. If you need it for something else, use a plain unindexed field such as legacy_userid — the mapping still works, but you lose the duplicate protection above.

Step 3: Backfill in chunks

The script counts the rows, walks the table in chunks of 4,000, sends each chunk to UserCreateBulk, and writes id plus the returned token into newusers. Failures are written to a CSV for review. Each chunk prints its offset before processing it, so if a run is interrupted you can see exactly where it stopped and resume from there:
Resuming matters on a large table. Without it a restart replays every chunk from the beginning — harmless, because the vault rejects records it already holds and ON CONFLICT DO NOTHING guards newusers, but it re-sends millions of profiles and fills the error log with duplicate reports that bury the genuine conflicts you need to act on.
Partial success is normal and is not an error. A batch of 4,000 can return 3,998 in created and 2 in errors. The usual reason is a duplicate email, phone, login, or custom value — Databunker Pro enforces uniqueness per tenant, so rows your old table allowed to coexist will be rejected here. Work through migration-errors.csv before continuing; those users have no token and no row in newusers, so they would disappear at the swap.

Step 4: Verify

1

Every row made it across

The counts must match, or differ only by the rows listed in migration-errors.csv.
2

Spot-check detokenization

Confirm the vault returns what the old table holds:
3

Confirm the vault count

SystemGetSystemStatstotalnumrecords should have grown by the number you migrated. See checking your usage.

Recover from an interrupted run

If the counts do not match, some rows reached the vault but their tokens were never written locally — the script died between the two writes. Because custom holds the legacy id, those tokens can be looked up again in one batch call:
BulkListUsers evaluates every criterion you send and is not paginated, so the whole missing set resolves in a single request. Ids with no vault record are simply absent from rows — those never reached the vault, so find them in migration-errors.csv and resolve the conflict there.
This call requires the list_users configuration flag to be enabled, otherwise it returns 403 BulkListUsers is disabled. The unlockuuid is short-lived, so request it immediately before the lookup — and for a very large missing set, work in batches with a fresh unlock for each.
Run this before Step 6. Once users has been renamed and dropped there is nothing left to compare against, and orphaned records cannot be found.

Step 5: Point the application at the vault

Do this before swapping anything.
  1. Dual-write. On user creation, call UserCreate and insert the returned token into newusers in the same transaction as the users row, so new users appear in both tables.
  2. Switch reads to fetch the profile from the vault by token. Joins that used email can join on usertoken — tokens are stable within a tenant, so this needs no round trip to the vault.
  3. Run in this mode for a full business cycle. Any code still reading a column directly surfaces here, while the data is present and recovery is free.

Step 6: Swap the tables

Once step 5 has run clean. In PostgreSQL, DDL is transactional, so both renames commit together and the cutover is effectively instant:
Keep users_old until you are satisfied, then drop it:
Foreign keys follow the table, not the name. Any table with a foreign key to users(id) now references users_old, because constraints track the table itself rather than its name. Check before swapping, and recreate the constraints against the new users afterwards:
Sequences, indexes, and constraints also keep their original names after a rename. They function normally, but newusers_pkey on a table now called users is confusing later — rename them if that matters to you.
Dropping users_old removes the personal data from the live table, but not from disk or from history. The values remain in dead tuples, the write-ahead log, replicas, and every backup taken before this point. Treat the PII as still present until VACUUM FULL has run and those backups have expired.

Rolling back

Next steps