GDPR-aware regional routing is two problems, not one: estimating where a request comes from, and deciding which data region your architecture or residency policy should use. An IP address helps with the first. GDPR does not impose a blanket rule that EU users' data must stay inside the EU, but it does regulate transfers of personal data to third countries.
Most write-ups pick a side. The code-first ones show you a country lookup and stop, with no mention of what the lookup is legally worth. The compliance-first ones explain the regulation and never show you a single line of routing. This is the piece in the middle: working detection and routing, plus an honest map of where IP geolocation stops being an answer.
TL;DR
-
Detect with an IP geolocation lookup.
country_code2and anis_euboolean are enough to make the routing decision. -
is_eucovers the EU-27, while this residency policy covers the full EEA-30. Add Norway, Iceland, and Liechtenstein explicitly so EEA-detected requests follow the same regional rule. - Route with edge middleware: read the client IP correctly (trust-proxy matters), map country to a data region, and store or process there.
- Fail toward the EEA region, not away from it. If your residency policy keeps EEA-detected requests in the EEA, routing an unknown request there is the safer default and avoids an accidental policy violation.
-
IP is a signal, not a lawful basis. VPNs, proxies, and mobile carriers break it, GDPR scope does not turn on IP, and an IP address is itself personal data.
If you want the short version: use the geolocation lookup to route, treat
is_euas a convenience rather than a compliance boundary, widen it to the EEA, and log every routing decision so you can defend it later. The rest of this walks through each step with code and the reasoning behind it.
What data-residency routing actually is
Data residency is a constraint on where data physically sits. Data-residency routing is the mechanism that honors whatever residency policy your product, customer contract, sector, or risk model requires. GDPR itself does not generally require personal data about EU or EEA users to be stored inside the EEA. Keeping relevant storage and processing inside the EEA can, however, simplify compliance by reducing the number of international transfers you need to account for.
Two adjacent ideas get tangled with it, and separating them keeps the design honest.
Data residency is not data sovereignty. Residency is the physical location of the bytes. Sovereignty is about which government's laws and courts have jurisdiction over that data, which can differ from where it is stored (a US-headquartered provider storing data in Frankfurt is a common sovereignty question). Routing solves residency directly. It only mitigates sovereignty.
Routing is not the same as IP minimization. Google Analytics, for instance, drops EU IP addresses before logging rather than routing anything. That is a valid pattern for one field. It is not a substitute for deciding where a user's account, orders, or messages live. Different problem, different tool.
The decision that determines where the data goes
Before any code, define what your routing policy actually means. If your policy is "keep requests detected in the EEA in an EEA region," is_eu alone is too narrow.
is_eu covers the 27 EU member states. The EEA also includes Iceland, Liechtenstein, and Norway, where the GDPR has been incorporated into the EEA framework. Those three are not EU members, so is_eu returns false for them. Add them explicitly when implementing an EEA-based residency policy.
This EEA routing boundary should not be confused with GDPR's territorial scope. Whether GDPR applies to a processing activity depends on Article 3 factors such as establishment, offering goods or services, and monitoring behavior, not simply on the user's detected country.
| Set | Members |
is_eu returns |
Route under this EEA-residency policy? |
|---|---|---|---|
| EU-27 | Austria, Germany, France, ... 24 more | true |
Yes |
| EEA extras | Iceland (IS), Liechtenstein (LI), Norway (NO) | false |
Yes |
| Everywhere else | US, IN, BR, ... | false |
No, unless your residency policy says otherwise |
Adequacy is a separate decision from IP-based routing. It concerns where personal data is being transferred, not where the user happens to be located.
If personal data is transferred from the EEA to a country, territory, sector, or organisation covered by a European Commission adequacy decision, that transfer can take place without an additional Chapter V safeguard. The Commission maintains the list and it changes, so treat it as current legal configuration rather than a hardcoded user-country list. Current examples include the United Kingdom, Switzerland, Japan, Republic of Korea, Brazil, New Zealand, Canada for covered commercial organisations, and participating US organisations under the EU-US Data Privacy Framework.
| Data path | GDPR transfer position | Practical implication |
|---|---|---|
| Relevant processing remains within the EEA | No third-country transfer under Chapter V | Use an EEA region when that matches your residency policy |
| Transfer from the EEA to an adequate destination | Article 45 adequacy decision | No additional Chapter V transfer safeguard is required for the covered transfer |
| Transfer from the EEA to another third country | Chapter V mechanism required, unless an applicable exception applies | Assess SCCs or another valid transfer mechanism separately from IP routing |
The nuance worth internalizing: keeping relevant storage, processing, and access inside the EEA can reduce international-transfer complexity. But an EEA database region does not by itself eliminate Chapter V questions if the data is remotely accessed, processed, supported, or onward-transferred from outside the EEA. Adequacy decisions and safeguards such as Standard Contractual Clauses address transfers when data does leave the EEA.
Detecting an EEA location by IP
The detection step is one HTTP call. Any IP geolocation provider returns the two fields that matter here: an ISO 3166-1 alpha-2 country code and, if the provider exposes one, an EU membership flag. ip-api, ipinfo, MaxMind GeoIP2, DB-IP, and IPGeolocation all return roughly the same country payload; pick by latency, pricing model (per-call API vs a local database), or what is already in your stack.
I'll use ipgeolocation.io for the examples because its free tier returns country_code2 and an is_eu boolean in a single call, which keeps the routing logic short. You can create a key on the free tier at ipgeolocation.io.
A quick look at the two fields with curl:
# Keep the key in the environment. Never commit it inside a URL.
export IPGEO_API_KEY="your_key_here"
# --max-time caps the whole request; a slow lookup should never block your request path.
curl -s --max-time 2 \
"https://api.ipgeolocation.io/v3/ipgeo?apiKey=${IPGEO_API_KEY}&ip=8.8.8.8" \
| jq '{country_code2: .location.country_code2, is_eu: .location.is_eu}'
For 8.8.8.8 (a US address) that returns country_code2: "US" and is_eu: false. The full response carries more than the routing decision needs, and it is worth seeing the shape once so you know what else is there:
{
"ip": "8.8.8.8",
"location": {
"continent_code": "NA",
"continent_name": "North America",
"country_code2": "US",
"country_code3": "USA",
"country_name": "United States",
"city": "Mountain View",
"zipcode": "94043",
"latitude": "37.42240",
"longitude": "-122.08421",
"is_eu": false,
"country_flag": "https://ipgeolocation.io/static/flags/us_64.png",
"geoname_id": "5375480"
},
"country_metadata": {
"calling_code": "+1",
"tld": ".us",
"languages": ["en-US", "es-US", "fr"]
},
"currency": { "code": "USD", "name": "US Dollar", "symbol": "$" }
}
For an EU member the same call returns the member's code ("DE", "FR", and so on) with is_eu: true. For an EEA-but-not-EU country like Norway, country_code2 is "NO" and is_eu is false, which is exactly why the routing code below checks both the flag and a short EEA list.
Heads up:
is_euis a convenience, not a compliance boundary. It answers "is this one of the 27 EU states," not "is this user in GDPR scope." The code widens it deliberately.
Routing the request to an EEA region
Now the mechanism. The pattern that scales is to decide the region once, as early in the request path as possible, and carry that decision downstream to whichever store or queue the data lands in.
Two things break this in practice if you skip them, so they are in the code rather than a footnote. First, reading the client IP correctly behind a proxy or load balancer. Second, deciding what happens when the lookup fails.
Here is Express middleware that resolves an IP to a target region:
// npm i express
const express = require("express");
const app = express();
// If you run behind a proxy/LB/CDN, configure Express to trust only the
// proxy path you actually control. req.ip is then derived from the socket
// address and X-Forwarded-For using that trust configuration.
//
// A numeric hop count is appropriate only when every request reaches the app
// through the same number of trusted hops. In production, trusting explicit
// proxy IPs or CIDRs is safer when your topology allows it.
app.set("trust proxy", 1); // illustrative: exactly one trusted proxy hop
// This residency policy covers the EEA: EU-27 (is_eu) plus these three.
const EEA_EXTRA = new Set(["IS", "LI", "NO"]);
// Country -> data region. Illustrative AWS names; swap for your provider.
// This is a residency-policy decision, separate from any Chapter V
// analysis of transfers to third countries.
const REGION_BY_GROUP = {
eea: "eu-central-1", // Frankfurt: keep EU/EEA data in the EU
other: "us-east-1",
};
async function resolveRegion(ip) {
const key = process.env.IPGEO_API_KEY;
const url = `https://api.ipgeolocation.io/v3/ipgeo?apiKey=${key}&ip=${ip}`;
try {
// 1.5s ceiling: a geolocation lookup must never hold the request open.
const res = await fetch(url, { signal: AbortSignal.timeout(1500) });
if (!res.ok) throw new Error(`lookup failed: ${res.status}`);
const data = await res.json();
const cc = data?.location?.country_code2 ?? null;
const isEu = data?.location?.is_eu ?? false;
// Missing country means the location is unknown, so use the protective default.
if (!cc) return REGION_BY_GROUP.eea;
if (isEu || EEA_EXTRA.has(cc)) return REGION_BY_GROUP.eea;
return REGION_BY_GROUP.other;
} catch (err) {
// Fail toward the protective region. Under this residency policy,
// an unknown location is treated as EEA rather than risking a mis-route.
console.error(`region lookup error for ${ip}: ${err.message}`);
return REGION_BY_GROUP.eea;
}
}
app.use(async (req, _res, next) => {
req.dataRegion = await resolveRegion(req.ip);
next();
});
req.dataRegion is now set for every request, and downstream handlers pick the database, bucket, or queue for that region. The two decisions that make it production-safe are the trust proxy setting (so req.ip is the client, not your load balancer) and the catch block that fails toward the EEA region rather than away from it.
The same logic in Python, as a FastAPI dependency:
# pip install fastapi httpx
import os
import httpx
from fastapi import Depends, Request
EEA_EXTRA = {"IS", "LI", "NO"} # EEA minus the EU-27 that is_eu covers
REGION_BY_GROUP = {"eea": "eu-central-1", "other": "us-east-1"}
async def resolve_region(request: Request) -> str:
# Behind a proxy, configure your server (e.g. uvicorn --forwarded-allow-ips)
# so request.client.host is the real client, not the proxy.
ip = request.client.host if request.client else ""
key = os.environ.get("IPGEO_API_KEY")
url = f"https://api.ipgeolocation.io/v3/ipgeo?apiKey={key}&ip={ip}"
try:
# (connect, read) timeouts keep a slow lookup off the request path.
async with httpx.AsyncClient(timeout=httpx.Timeout(1.5, connect=1.0)) as client:
resp = await client.get(url)
resp.raise_for_status()
loc = resp.json().get("location", {})
cc = loc.get("country_code2")
is_eu = loc.get("is_eu", False)
# Missing country means the location is unknown, so use the protective default.
if not cc:
return REGION_BY_GROUP["eea"]
if is_eu or cc in EEA_EXTRA:
return REGION_BY_GROUP["eea"]
return REGION_BY_GROUP["other"]
except Exception as err:
# Unknown location routes to the EEA region under this protective residency policy.
print(f"region lookup error for {ip}: {err}")
return REGION_BY_GROUP["eea"]
# Usage: def handler(region: str = Depends(resolve_region)): ...
Both versions make the same call: check is_eu, widen it with the EEA set, and fall to the EEA region on failure.
Country to region, as a table
The dictionary in the code is the compact version of this decision. Spelled out:
| Detected group | Example codes | Route under this residency policy |
|---|---|---|
EU-27 (is_eu: true) |
DE, FR, ES, IT, PL | EEA region (e.g. eu-central-1) |
| EEA extras | IS, LI, NO | EEA region |
| Non-EEA | US, GB, JP, BR, IN | Default or configured region |
| Lookup failed | n/a | EEA region (protective default) |
Fail open or fail closed
This is the decision people skip, and for residency it is the one with legal weight. Fail open (route an unknown location to the default region) optimizes for availability and cost. A protective default (route it to the EEA region) is useful when your own residency policy requires EEA-detected traffic to remain there. For that policy, I fail toward the EEA region: occasionally routing a non-EEA request to Frankfurt costs some latency, while routing an EEA request to the wrong region violates the policy the router was built to enforce.
Where IP geolocation stops being enough
Everything above is a routing heuristic. It is genuinely useful and it is not a compliance determination, and conflating the two is how teams get surprised in an audit. Three boundaries matter.
IP location is a signal, not a lawful basis. GDPR's territorial scope does not turn on a user's IP. It applies to processing by an establishment in the EU, and to offering goods or services to, or monitoring, people in the EU, regardless of where the processing happens (GDPR Article 3). A US company with EU customers can be fully in scope even if it never runs an IP lookup. Use geolocation to route data efficiently; do not use it to decide whether the regulation applies to you. The EDPB territorial-scope guidelines are the reference if you need to reason about that line carefully.
VPNs, proxies, and mobile carriers break the signal. A user in Berlin on a corporate VPN that egresses in Virginia looks American. A traveler in the EU on a mobile network can geolocate to the carrier's registered country rather than their cell. Country-level accuracy is high on ordinary residential and business IPs and drops on VPN egress, datacenter ranges, and some mobile carrier blocks. If a mis-route has consequences, corroborate the IP signal with account data (a billing country, a stored preference, an explicit region choice) instead of trusting it alone. Providers that flag VPN, proxy, and hosting IPs let you at least detect when the signal is shaky.
An IP address is personal data. In the context of a service handling identifiable users, you should generally treat client IP addresses as personal data when they can reasonably be linked to a person. In Breyer v Germany (C-582/14), the Court of Justice held that a dynamic IP address can constitute personal data for a website operator where the operator has legal means reasonably available to identify the user with additional information. Practical consequence: the lookup you run to decide residency may itself involve processing personal data. If you send that IP to an external geolocation provider, also account for where that provider processes it and whether the lookup creates a third-country transfer. Keep the lookup purposeful, review the provider's processing arrangement, avoid storing raw IPs longer than you need, and pseudonymize where appropriate.
| IP geolocation can establish | IP geolocation cannot establish |
|---|---|
| A probable country for routing | Legal residence or citizenship |
| A rough EU/EEA vs non-EU split | Whether GDPR applies to your processing |
| A fast first-pass region decision | A lawful basis or valid consent |
| A hint that a connection is anonymized | Where a user actually is, when they use a VPN |
Where transfers out of the EEA are genuinely necessary, the mechanisms are adequacy decisions and Chapter V safeguards such as Standard Contractual Clauses, documented on the Commission's pages for adequacy and international transfers. Keeping the relevant data and access paths within the EEA can avoid a third-country transfer. An EEA storage region alone is not sufficient if processing or access still occurs from outside the EEA.
A reference checklist
Keep this next to the implementation:
- Route on the EEA-30, not just
is_eu(EU-27). Add IS, LI, NO. - Read the client IP through your proxy configuration, not a raw header.
- Set a hard timeout on the lookup (1 to 2 seconds) so it never blocks the request path.
- Fail toward the EEA region when the lookup is unavailable or ambiguous.
- Treat the adequacy list as a lookup, not a hardcoded constant; the Commission updates it.
- Log the routing decision (IP-derived country, chosen region, timestamp) so you can defend it.
- Corroborate with account data when a mis-route has real consequences.
- Remember the lookup itself processes personal data; minimize and pseudonymize. ## A few extra notes
Doing the lookup at the CDN edge and passing a trusted country header to your app beats calling an API on every request. Cloudflare, Fastly, and AWS CloudFront can attach a country code at the edge; your app then reads a header you control instead of paying a network round trip per request. If you are calling a geolocation API synchronously on every request with no cache, that is the first thing I would change. Cache exact-IP results with a short TTL, or preferably move the lookup to a trusted edge layer that already provides the country signal. For policy-sensitive routing, avoid broadening one IP's result across an entire /24.
IPv6 needs the same treatment as IPv4. If your service is dual-stack, make sure the lookup path handles a v6 client address, and test it, because v6 country accuracy on some ranges lags v4.
The UK is worth a specific note. Since Brexit it is a third country, but the European Commission renewed its GDPR adequacy decision in December 2025. That means personal data can currently be transferred from the EEA to the UK under Article 45 without an additional Chapter V safeguard. This is about the transfer destination, not about whether a UK user's data must be stored in any particular region. UK GDPR may separately apply to your processing.
Within the routing condition itself, EEA_EXTRA is the only jurisdiction list you hardcode. Keep IS, LI, and NO in one named constant rather than scattering them across handlers. Treat adequacy decisions, transfer mechanisms, proxy configuration, and your own residency policy as separate configuration that may also change over time.
Where to take it next
Drop the middleware into your request path, point the region map at your actual providers, and add the routing decision to your logs before you ship it. If you only harden one thing afterward, move the lookup to the edge with a cache so you are not paying for it per request. And keep the distinction in view: this routes data correctly, it does not decide whether GDPR applies to you. That question was answered long before the IP lookup ran.
Top comments (0)