Skip to main content

Data residency - what lives where

Every piece of data lives in one of three tiers. Pick the tier before you design a table. Getting it wrong is the most expensive mistake in this whole module.

How it works​

TierHoldsWhere it is storedWho writesCopied to other regions?
RegionalAnything with a person's name on itThe user's home region onlyCore API in that regionNever
GlobalContent that is the same for every userOne primary region, read replicas in every regionCore API, primary region onlyYes, read-only
Identity registryHashed identity and home region. Nothing elseGlobal table, one replica per regionCore API, at sign-upYes

A read replica is a copy of a database that you can read from but not write to. It lets every region read content locally and fast.

A hash turns an email into a fixed string you cannot turn back. An HMAC is a hash mixed with a secret key, so nobody without the key can rebuild the list.

How to pick the tier of a new table​

The one question to ask every time:

Does this row belong to a person, or to the app? A person's row is regional. The app's row is global.

Examples:

DataTierWhy
A user's profileRegionalIt is the user's
A note the user wrote on an articleRegionalThe note is the user's
The article itselfGlobalIt is everyone's
A user's private shortlistRegionalThe user's
A staff-curated catalogueGlobalThe app's
A post in a group with members from many countriesHard caseSee the mistake below

How to build it​

  1. Run two Postgres clusters per region: one regional, one global (the global one is a replica outside the primary region).
  2. Give the code four connections: regional write, regional read, global write, global read. A query never mixes them.
  3. Each module gets its own Postgres schema (a named group of tables) inside the right cluster.
  4. Do not add foreign-key constraints in the database. A regional row can point at a global row, and Postgres cannot enforce a key across two clusters. Check it in code.
  5. A change that touches both tiers is never one transaction. Write the regional change first, add an outbox row in the same transaction, then let a background worker do the global write. An outbox is a table of "work still to do" that survives crashes.
  6. Store the user's home region at sign-up. Every later request reads it.

Idempotent means running it twice has the same effect as running it once. The worker may retry, so it must be safe to repeat.

Residency is not one region per country​

You do not need an AWS region in every country. A few regions can serve many countries, as long as the legal basis for each transfer is written down. In our plan (as of August 2026), seven target markets mapped onto four AWS regions. Some countries have no AWS region at all and are served from a neighbour.

The legal side is a decision for the privacy lead and counsel, not for engineering. Engineering's job is to make the routing correct and to be able to say, for every table, which region holds it.

Rules​

Rule

Personal data is never replicated out of its home region. Not by database replication, not by snapshot copy, not by a log line. If it has a person's name on it, it stays home.

Rule

Never write "the global tier is content only" unless you have listed every schema in it. Ours was not, and the plan to replicate it everywhere was built on that wrong belief.

Rule

Files are data. User uploads, avatars and generated audio go to a regional bucket. Only app content goes in the global CDN bucket.

Rule

Build a way to move a user to another region before you launch a second region. Wrong home regions will happen, and without a transfer process they are permanent.

Mistakes we made​

  • The "global" tier ended up holding user-generated content. All the community tables (group chat messages, posts, replies, membership lists, and moderation reports with free-text allegations) and all staff accounts (emails, password hashes, IP addresses) lived in the global cluster. It was deliberate: showing one group's timeline would have meant querying every member's home region for every post. But the docs still said "global means content". It matters because the multi-region plan was to replicate the global tier to every region. That would have copied personal data, some of it sensitive (religious or health topics are "special category" data under the GDPR, Article 9, as of October 2026), into every country. Instead: name the tier honestly (for example "shared", not "content"), keep a list of every schema in it, and treat any replication of it as a privacy decision that needs sign-off.
  • User files sat in the global assets bucket. Content files and user uploads shared one bucket under different prefixes, in one region. Instead: two buckets from day one, a global content bucket behind the CDN and a regional user-files bucket.
  • Home region was immutable, with no way to move a user. The schema said "never changes, even if the user moves". Combined with a client that fell back to a default region (see Region discovery), one bad first launch would place a user in the wrong country forever. Instead: design an operator-run transfer: export, import into the target region, repoint the registry, verify, delete from the source.
  • Guest data was written before the region was known. Anonymous onboarding saved answers on the server before sign-up, in whichever region discovery picked. Instead: keep guest answers on the device, or in a region-neutral store, until the region is confirmed.

Checklist​

  • Every new table has a tier written in its design note.
  • No regional table is in a replicated cluster.
  • No foreign-key constraints cross tiers.
  • Cross-tier writes go through an outbox.
  • User files go to a regional bucket.
  • I have listed every schema in the global tier, not just the ones I expect.