Most multi-region designs start from latency: put a copy of the data near every user so reads are fast. Data residency flips the question. It is not about where copies should be for speed, but where copies must not be. A contract or a privacy law can require that a customer's records stay inside named regions, which constrains replication, backups, logs and every pipeline that touches the data.
Geo-partitioning is the technique that satisfies it: each row (or each tenant) gets a home region, and the storage layer places that row's replicas only in regions allowed for it. This article explains how to choose the key that decides the home region, compares region cells with row-level placement in one logical database, shows real CockroachDB and YugabyteDB statements, walks through the places personal data leaks out of a partition, and works through moving a customer from one region to another.
The architecture at a glance
Residency is not latency
Two requirements get confused because both say 'region'. Latency wants data close to the user who reads it, and is happy to keep extra copies anywhere. Residency (also called domiciling or localisation) restricts where data may be stored, and sometimes where it may be processed or who may access it. Latency can be traded away under load; residency is broken by a single misplaced replica, backup or log line.
Be precise about which rule you are implementing, because the engineering differs. The EU's GDPR, for example, does not by itself say that personal data must be stored in the EU; it restricts transfers to other countries unless safeguards apply. Contracts and other laws are often stricter. Your counsel decides the rule; your design must make that rule enforceable and auditable. Write it down as a table: data class, allowed storage, processing and support-access regions.
The home-region key
Getting the home key wrong is the most expensive mistake, because changing it means moving data.
- Derive it from the contract, not the request. The IP address of today's request tells you where the user is sitting, not where their data is allowed to live. Set the home region when the account or tenant is created, from the billing country or the contract, and store it.
- Partition by tenant where you can. In B2B products the customer organisation is the natural unit; per-user homes suit consumer products.
- Put it in every primary key. If the region column leads the key of every personal table, rows for one region form contiguous ranges that the database can pin to that region's nodes, and every query that includes it can be routed without a fan-out.
- Make it hard to change. Re-homing is a separate, audited workflow (below), not an UPDATE anyone can issue.
Two shapes: cells and row-level placement
There are two broad ways to build this, and a common hybrid.
| Region cells | Row-level placement in one database | |
|---|---|---|
| Shape | A full, independent stack per jurisdiction: its own database cluster, caches, queues and services | One logical cluster spanning regions; the database pins each row's replicas by its region column |
| Cross-region queries | Impossible by construction; reporting needs a separate, approved path | Possible in SQL, which is both a convenience and a leak risk |
| Blast radius | A bad deploy or outage hits one cell | Shared control plane, schema changes and upgrades hit every region |
| Operational cost | N clusters to patch, size and back up | One cluster, but with placement rules you must audit |
| Good fit | Few jurisdictions, strict rules, tenants that never interact | Many regions, users that interact across regions, global reference data |
The hybrid is the most common in practice: region cells for the heavy personal data (documents, messages, events), plus a small global tier holding only the directory that maps tenants to cells and non-personal reference data. If you start with cells, make the directory pattern from directory-based sharding your router, with the cell as the shard.
Row-level placement in SQL
Distributed SQL databases expose row-level placement directly. In CockroachDB, a multi-region database has a primary region and member regions. A table marked REGIONAL BY ROW keeps each row's replicas homed in the region named by a region column, and a table marked GLOBAL is optimised for fast reads everywhere. A super region is a set of database regions; regional data homed in a super region keeps all its replicas, voting and non-voting, inside that set.
-- CockroachDB. The cluster's nodes already carry --locality=region=...
ALTER DATABASE app PRIMARY REGION "europe-west1";
ALTER DATABASE app ADD REGION "europe-west3";
ALTER DATABASE app ADD REGION "us-east1";
ALTER DATABASE app ADD REGION "us-west1";
-- Reference data with no personal fields: replicated for reads everywhere.
ALTER TABLE plans SET LOCALITY GLOBAL;
-- Personal data: each row lives in the region in its "region" column,
-- which must be of the database's region enum type.
ALTER TABLE customers SET LOCALITY REGIONAL BY ROW AS "region";
ALTER TABLE invoices SET LOCALITY REGIONAL BY ROW AS "region";
-- Keep EU-homed rows, including non-voting replicas, inside the EU regions.
ALTER DATABASE app ADD SUPER REGION "eu" VALUES "europe-west1", "europe-west3";Two details matter. First, with a super region of two regions you cannot survive the loss of one region and still keep three voting replicas spread three ways; decide your survival goal per jurisdiction and add a third region inside the boundary if you need region failure survival there. Second, CockroachDB's own documentation lists what is not domiciled: a subset of indexed column data can appear in meta ranges and system tables replicated across the cluster, log files can contain data from any region, and the replication system may override placement rules when that is the only way to avoid losing data. Primary keys and indexed columns therefore should not contain personal data such as e-mail addresses. Use an opaque ID in the key and keep the e-mail in a non-indexed column, or in a hashed lookup column. The older PLACEMENT RESTRICTED option still exists for compatibility; the documentation recommends super regions instead.
YugabyteDB reaches the same result with PostgreSQL syntax: a tablespace describes allowed placement, and list partitions of a table are assigned to tablespaces.
-- YugabyteDB: one tablespace per jurisdiction, one partition per tablespace.
CREATE TABLESPACE eu_ts WITH (replica_placement='{"num_replicas": 3, "placement_blocks": [
{"cloud":"aws","region":"eu-central-1","zone":"eu-central-1a","min_num_replicas":1},
{"cloud":"aws","region":"eu-central-1","zone":"eu-central-1b","min_num_replicas":1},
{"cloud":"aws","region":"eu-west-1","zone":"eu-west-1a","min_num_replicas":1}]}');
CREATE TABLE customers (
region text NOT NULL,
id uuid NOT NULL,
name text,
PRIMARY KEY (region, id)
) PARTITION BY LIST (region);
CREATE TABLE customers_eu PARTITION OF customers FOR VALUES IN ('eu') TABLESPACE eu_ts;
CREATE TABLE customers_us PARTITION OF customers FOR VALUES IN ('us') TABLESPACE us_ts;
Global data and cross-region features
Some data really is global: product plans, feature flags, currency tables, the residency directory itself. Keep that tier deliberately small and free of personal fields. The directory is the tricky one, because it maps a tenant to its home and is read on every request. Store only an opaque tenant ID, the home region and a version number; do not store the company name or an admin's e-mail address in it, or the global tier quietly becomes a copy of personal data in every region.
Cross-region features need explicit design. If an EU user shares a document with a US user, decide which partition owns the document (normally the author's), and serve the US reader by a remote read into the EU partition rather than by copying the document into the US. Remote reads cost an inter-continental round trip, which is the price of the rule. cloud region selection covers the latency physics.
Where personal data leaks
The database is the easy part. Personal data escapes through everything around it. Run this audit for each data class before launch and again after every new pipeline:
| Path | How it leaks | Control |
|---|---|---|
| Application logs and traces | Request bodies, e-mails or IDs logged by a service and shipped to a central log store in one region | Per-region log pipelines, field redaction at the source, trace attributes on an allow-list |
| Backups and exports | A cluster backup or dump written to a bucket in the default region | Per-region backup destinations; deny bucket policies outside the allowed regions |
| Change data capture | A changefeed or Kafka topic consumed by a global analytics job | Region-scoped topics; aggregate or pseudonymise before any cross-region hop |
| Caches and search | A global cache or search cluster indexing user profiles | One cache and index per region, keyed by home region |
| Warehouse and ML | Nightly copies of all regions into one warehouse or training set | Per-region warehouses, or only anonymised aggregates leave the region |
| Support tooling | Support staff in another country viewing records through an admin panel | Access policy by home region and staff location, with audit logging |
| Keys | Encryption keys for EU data held in a key service in another region | Per-region key management; data and its keys share a boundary |
Routing requests to the home region
Routing has two stages. The edge (geo DNS or an anycast load balancer) sends a request to the nearest point of presence, which is a latency decision. The application then resolves the tenant's home region from the directory, which is a residency decision, and forwards the request there. Forward rather than redirect the client. The edge should forward without logging the body, so a German user hitting a US edge does not leave a copy of their payload in US logs.
# Pseudocode for the edge router. Directory entries are small and cached
# with a version; the cell rejects requests whose version is stale.
def route(request):
tenant = authenticate(request) # token carries tenant_id only
home, version = directory.cached_lookup(tenant)
if home == LOCAL_REGION:
return handle_locally(request)
# Forward over the private backbone; never persist the body here.
return forward(request, to=cell_endpoint(home),
headers={"X-Home-Version": str(version)})
def cell_accept(request, tenant):
home, version = directory.authoritative(tenant)
if home != LOCAL_REGION: # tenant moved: reject, edge re-resolves
raise WrongRegion(home, version)
return handle_locally(request)The second function is the safety net. Each cell refuses writes for tenants it does not own. Without it, a stale edge cache writes into the old region during a move.
Worked example: re-homing a tenant
Worked example. A B2B analytics SaaS has 4,000 tenants: 2,600 in the US, 1,200 in the EU and 200 in India. Contracts require EU tenants' data to stay in the EU and Indian government tenants' data in India. The team chooses cells for the event store (large, append-heavy, never queried across tenants) and a CockroachDB cluster with REGIONAL BY ROW for account and billing tables, which some global back-office reports read in aggregate.
A customer moves its contract from its US entity to its German subsidiary, so its home must change from US to EU. The re-homing workflow:
- Mark the tenant moving in the directory with a new version. Writes continue in the US.
- Copy the tenant's event data from the US cell to the EU cell in the background, recording a high-water mark (the last event ID copied).
- Briefly fence writes: set the directory entry to frozen, wait for in-flight requests to drain (seconds), copy the tail after the high-water mark and verify counts and checksums per table.
- Flip the directory to EU with version + 1. Cells now reject the old version, so stale edges re-resolve.
- In the database tier, a single UPDATE of the region column moves the account rows; CockroachDB rehomes them to the new region's replicas.
- Delete the US copy, including backups past their retention window, and record the deletion for the audit trail.
The fence in step 3 is the only downtime, and for a tenant with a few gigabytes of tail it is seconds. Skipping it splits the history across two regions.
Failure modes
- Personal data in keys. E-mail addresses as primary keys or in indexed columns can appear in cluster-wide metadata. Use opaque IDs.
- Region from IP. Homes assigned at sign-up from the request IP put travelling users in the wrong partition, permanently.
- The failover that crosses the border. A disaster-recovery plan that promotes a replica in another jurisdiction violates the rule exactly when everyone is too busy to notice. Keep DR targets inside each boundary, or document that the contract permits the exception.
- Directory staleness. Without cell-side ownership checks, cached routing writes to the old home during a move.
Trade-offs
Geo-partitioning buys compliance and usually better latency for local users, and pays in three currencies. Cost: each jurisdiction needs enough nodes for its own fault tolerance, so a region with 5% of the users may cost 20% of the bill. Features: anything that joins across homes (global search, cross-tenant analytics, social graphs) becomes a remote call or an aggregate-only pipeline. Operations: more clusters or more placement rules, both of which need continuous auditing. Cells give the strongest isolation and the simplest audit story; row-level placement gives the best developer experience and the easiest re-homing. See geo-distributed systems for the latency side and the CockroachDB deep dive for how ranges and leaseholders behave underneath.
What to do next
- Write the residency table with counsel: data class, allowed storage regions, processing regions, support access.
- Choose the home key (normally the tenant) and add it as the leading column of every personal table's primary key.
- Decide cells, row-level placement or the hybrid, using the comparison table above.
- Remove personal data from primary keys, indexed columns and the global directory.
- Run the leak audit for logs, backups, CDC, caches, warehouse, support tools and keys; give each an owner.
- Add cell-side ownership checks and a versioned directory before you build re-homing.
- Rehearse a re-homing and a regional failover in staging, and check that neither puts a replica outside the boundary.
- Automate a placement test that fails the build if any resource is declared outside its allowed regions.