GDPR-conscious geocoding for EU addresses: a minimal-retention pattern

Geocode EU customer addresses at intake, keep only coordinates and boundary codes, avoid logging raw strings. Engineering pattern — not legal advice.

| August 24, 2026
GDPR-conscious geocoding for EU addresses: a minimal-retention pattern

> Disclaimer. This post describes an engineering pattern, not legal advice. It makes no compliance claims about CSV2GEO or about your system. Talk to your DPO and legal counsel before finalising any data-handling design for personal data.

---

EU customer addresses are personal data. Under data-minimisation principles — Article 5(1)(c) of the GDPR, if you want the citation for your DPO — you should only process and retain what is strictly necessary for the purpose you have stated. For most applications, that purpose does not require retaining the raw address string. It requires a coordinate for delivery routing, a boundary code for tax jurisdiction, or a postcode area for regional analytics. The raw string — the one that includes a person's street number, city, and country linked to their account — is overhead once the geocode is done.

The pattern this post describes is not novel. Healthcare teams apply it to patient addresses under HIPAA (the engineering logic is identical; see the HIPAA-safe patient address pipeline post for the healthcare version). The goal here is to make the same pattern concrete for EU-facing engineering teams who do not have a healthcare-specific compliance mandate but do have a DPO asking hard questions about what sits in which table.

This post covers: why raw strings accumulate, how to geocode at intake and drop the string before it persists, how to use the Boundaries endpoint for area-level aggregation, how to avoid logging raw addresses in your observability stack, and what a documented data-flow diagram for this pipeline looks like. Code examples are REST — curl, Python requests, and Node fetch. SDKs exist and are useful; the REST pattern is shown here because it documents the data flow explicitly, which is exactly what your DPO needs to review.

Why raw address strings accumulate in the first place

The honest answer is convenience. When a user types their address into a checkout form, the path of least resistance is to write the whole string to the users.address column and geocode later, or never. That convenience becomes a liability when you later need to:

  • Respond to a Subject Access Request — you now need to find every table and log where the address string appears.
  • Process a Right to Erasure request — deleting the canonical row is easy; finding the raw string that leaked into an analytics warehouse, an email marketing platform, or an observability log is not.
  • Defend your retention policy to a supervisory authority — "we keep addresses because the geocode job hasn't run yet and we're not sure if we'll need the raw string again" is not a convincing lawful-basis argument.

The technical fix is to geocode at the point of intake and decide what you actually need before anything is persisted. The raw string becomes ephemeral — it exists in memory for the duration of the geocode call, and then it is gone.

What you actually need vs what you habitually store

Before writing any code, map your use cases to the minimal field set that satisfies each one.

| Use case | What you need | What you do NOT need to persist | |---|---|---| | Delivery routing | lat, lng | Street, city, raw string | | Tax jurisdiction | boundary_code (region/country) | Street, city, raw string | | Regional analytics | Aggregated area (postcode district, NUTS-3 region) | Individual coordinates | | Fraud signal (postcode mismatch) | Postcode sector or district | Full street address | | Address validation at checkout | confidence score, boolean valid | The validated string itself |

Work through this table with your product and legal teams before you build. The technical pattern is the same regardless of which columns you decide to keep; what changes is the policy that decides the answer.

The intake pattern — geocode first, persist last

The core flow:

  1. User submits an address string in a form.
  2. Your application layer calls the geocode endpoint — over TLS, no logging of the raw string in your application logs.
  3. The API returns coordinates, confidence, and optionally a boundary code.
  4. You write only the minimal field set to your database. The raw string is discarded.
  5. If confidence is below your threshold, you surface a validation error to the user and ask them to correct — you do not persist a low-confidence address that you'll later struggle to explain.

The geocode call is one HTTP request. The request is made from your server, not from the user's browser — the API key stays server-side, and the raw address string never touches a third-party system that is outside your data-processing agreement scope.

Geocoding a single address at intake

curl -G "https://csv2geo.com/api/v1/geocode" \
  --data-urlencode "q=Marktplatz 1, 70173 Stuttgart, Germany" \
  --data-urlencode "api_key=$CSV2GEO_API_KEY"

A minimal response looks like:

{
  "results": [
    {
      "lat": 48.7784,
      "lng": 9.1800,
      "confidence": 0.94,
      "country_code": "DE",
      "postcode": "70173"
    }
  ]
}

What you persist from this response depends on your use-case table above. For a delivery-routing application, you write lat and lng. For a tax-jurisdiction application, you write country_code. For both, you write both. You do not write q — the raw address — anywhere.

Here is the same call in Python, structured as an intake handler that returns only what the application needs:

import os
import requests

API = "https://csv2geo.com/api/v1/geocode"
KEY = os.environ["CSV2GEO_API_KEY"]
CONFIDENCE_THRESHOLD = 0.75

def geocode_at_intake(raw_address: str) -> dict | None:
    """
    Geocode a raw address and return only the minimal field set.
    The raw address string is never written to any persistent store.
    Returns None if confidence is below threshold — caller should
    surface a validation error rather than persisting a bad geocode.
    """
    r = requests.get(
        API,
        params={"q": raw_address, "api_key": KEY},
        timeout=15,
    )
    r.raise_for_status()
    results = r.json().get("results", [])
    if not results:
        return None
    best = results[0]
    if best.get("confidence", 0) < CONFIDENCE_THRESHOLD:
        return None
    # Return ONLY what the use case requires. Never return raw_address.
    return {
        "lat": best["lat"],
        "lng": best["lng"],
        "confidence": best["confidence"],
        "country_code": best.get("country_code"),
        "postcode_district": best.get("postcode", "")[:4],  # e.g. "7017" from "70173"
    }

Notice what this function does not do: it does not log raw_address, it does not return raw_address, and it does not cache raw_address. From the perspective of any downstream system, the address string never existed. The only artefact is the coordinate and the boundary fields.

And the equivalent in Node:

const API = 'https://csv2geo.com/api/v1/geocode';
const KEY = process.env.CSV2GEO_API_KEY;
const CONFIDENCE_THRESHOLD = 0.75;

async function geocodeAtIntake(rawAddress) {
  const url = new URL(API);
  url.searchParams.set('q', rawAddress);
  url.searchParams.set('api_key', KEY);

  const r = await fetch(url.toString());
  if (!r.ok) throw new Error(`geocode http ${r.status}`);
  const body = await r.json();
  const results = body.results ?? [];
  if (!results.length) return null;

  const best = results[0];
  if ((best.confidence ?? 0) < CONFIDENCE_THRESHOLD) return null;

  // rawAddress is never written anywhere from this point.
  return {
    lat: best.lat,
    lng: best.lng,
    confidence: best.confidence,
    countryCode: best.country_code ?? null,
    postcodeDistrict: (best.postcode ?? '').slice(0, 4),
  };
}

Using the Boundaries endpoint for area-level aggregation

For analytics — regional sales dashboards, delivery-density maps, policy-territory reporting — you often do not need individual coordinates at all. You need counts and aggregates per area. The Boundaries endpoint lets you resolve a coordinate to a named boundary (country, region, NUTS area, postcode district) at query time, so you can aggregate without ever storing a per-person coordinate in your analytics warehouse.

The pattern:

  1. At intake, you store the minimal set including lat and lng in your transactional database.
  2. When building analytics, you call the Boundaries endpoint to resolve each coordinate to the appropriate area code for the granularity your report needs.
  3. You aggregate by area code and write only the aggregate to the analytics store — no per-person coordinates cross the boundary into the warehouse.

A Boundaries call for a coordinate in Stuttgart:

curl -G "https://csv2geo.com/api/v1/boundaries" \
  --data-urlencode "lat=48.7784" \
  --data-urlencode "lng=9.1800" \
  --data-urlencode "api_key=$CSV2GEO_API_KEY"

Returns the boundary memberships for that coordinate — country, region, and sub-region codes depending on coverage for that country. You can join those codes to your existing transactional rows at analytics time and then discard the coordinates, keeping only the aggregate counts per boundary in your reporting layer.

For teams building this as a batch job across a large customer table:

import csv
import os
import requests

API = "https://csv2geo.com/api/v1/boundaries"
KEY = os.environ["CSV2GEO_API_KEY"]

def resolve_boundary(lat: float, lng: float) -> dict:
    r = requests.get(
        API,
        params={"lat": lat, "lng": lng, "api_key": KEY},
        timeout=15,
    )
    r.raise_for_status()
    result = r.json().get("result", {})
    # Return only the area code needed for aggregation.
    return {
        "country_code": result.get("country_code"),
        "region_code": result.get("region_code"),
        "nuts3": result.get("nuts3_code"),  # or whatever level is appropriate
    }

# In a batch job:
# - Read lat/lng from transactional DB
# - Resolve to boundary codes
# - Aggregate: {region_code: count} — NO per-person rows in output
# - Write aggregates to analytics store

The key discipline: the output of this job is a table of {region_code, count, metric} rows, not a table of {user_id, region_code} rows. The join key (user_id) never enters the analytics store.

Avoiding raw addresses in your observability stack

This is where teams get tripped up. They implement the intake pattern correctly at the application layer, then discover that their structured logs include {"event": "geocode_called", "address": "Hauptstrasse 12, 10115 Berlin"} because someone added a debug log line that concatenated the request parameters.

Observability is legitimate and necessary — you need to know your geocode pipeline is healthy. But the metrics and traces you need for that purpose do not require the raw address string. They require:

  • Request count, latency distribution, error rate per status code.
  • Confidence score distribution (are you getting more low-confidence results this week than last?).
  • Country code distribution (are requests shifting from DE to FR?).
  • Whether a given user_id had a successful geocode — not what address they submitted.

Concrete logging rules for your team:

Log: confidence, country_code, postcode_district, lat rounded to 2 decimal places (a 2dp coordinate resolves to a ~1 km grid square — not a personal address), HTTP status code, latency in ms, a request UUID.

Do not log: The q parameter, the raw address string, the full postcode (which combined with other fields may be re-identifying), the full-precision coordinate if that alone identifies a residence.

A structured log entry that is safe:

{
  "event": "geocode_intake",
  "request_id": "a3f9c2d1",
  "user_id": "usr_4892",
  "status": 200,
  "confidence": 0.94,
  "country_code": "DE",
  "lat_2dp": 48.78,
  "lng_2dp": 9.18,
  "latency_ms": 112
}

That entry tells you everything you need to operate the pipeline. It tells you nothing about where the user lives. See Observability for Geocoding Pipelines for the full instrumentation pattern — the principle of separating operational signals from personal-data fields applies directly here.

Handling low-confidence geocodes without storing the raw address

One objection to the "discard the raw string" pattern is: what if the geocode fails or returns low confidence? Do you not need to store the raw address to retry later?

The answer is: surface the validation failure to the user immediately, at the point of submission, and ask them to correct their input. Do not save a low-confidence or failed geocode attempt to retry later — that defers the problem and creates a store of uncleansed address strings with uncertain data-minimisation status.

The user experience is: "We could not verify that address — please check the street number and postcode." That is better UX than silently storing a bad geocode and shipping to the wrong postcode district three days later anyway.

If you have a batch-import use case where addresses arrive programmatically (a B2B customer uploading a CSV), the same principle applies: fail the row at import time, return the row to the sender with an error flag, and do not retain the failed string. The sender's system is the authoritative source; let them fix and re-submit.

See Geocoding Confidence Scores Explained for how to interpret the confidence field and what threshold is appropriate for different use cases.

Documenting the flow for your DPO

Your DPO needs a data-flow diagram they can review, and a record of the decision about what is retained and what is discarded. Here is the minimal description that covers this pipeline:

Data received: Raw postal address string, submitted by the data subject via a form or API call.

Processing: The raw address is submitted to a third-party geocoding API (CSV2GEO) over TLS. The call is made server-side. The raw address is not logged. The API returns coordinates, confidence, and boundary codes.

Data retained: lat (float), lng (float), confidence (float), country_code (string), postcode_district (string, truncated to district level). Retained in the transactional database linked to the user's account.

Data discarded: The raw address string. It is held in application memory only for the duration of the geocode call — typically under 200 ms — and then garbage-collected. No log, no cache, no secondary storage.

Retention period: The retained fields are retained for the same period as the user account, under the same retention policy, and are deleted on account closure or Subject Access Erasure request.

Third-party processing: CSV2GEO processes the raw address to return a geocode result. Review the current CSV2GEO terms and your own data-processing agreement requirements. This is a decision for your DPO and legal counsel, not this blog post.

That last paragraph bears repeating in the context of the whole post: we are describing an engineering pattern that minimises the personal data your own systems touch and retain. Whether the pattern satisfies your specific obligations under GDPR or any national implementation of it is a legal question. Talk to your DPO.

How to implement this — step by step

Step 1: Audit what you currently store

Before changing any code, run a query across your schema to find every column that holds a free-text address string. Include your analytics warehouse, your email marketing integrations, your support ticketing system, and your application logs. You cannot minimise what you have not mapped.

Step 2: Add a geocode-at-intake function to your intake layer

Using the Python or Node pattern above, add a function to your intake layer that takes the raw address, calls the geocode endpoint, and returns only the minimal field set. The function must not log or return the raw address. Deploy this alongside — not replacing — your current flow initially, and verify the output matches expectations on a sample of real (but anonymised or consented) addresses.

Step 3: Update your schema to store the minimal field set

Add lat FLOAT, lng FLOAT, confidence FLOAT, country_code VARCHAR(2), postcode_district VARCHAR(8) to your user or address table. If you are retaining the raw address today, mark the existing column for deprecation in a time-boxed migration plan. Do not delete it until your geocode-at-intake flow is verified in production and your DPO has signed off on the cutover.

Step 4: Harden your logging configuration

Audit your structured-logging configuration for any place the q parameter, address, or street fields could be included in a log line. Add an explicit allowlist to your log serialiser — only the fields listed in the "Log this, not that" section above are emitted. For teams using a log-management platform, add a pipeline rule that scrubs any field matching a postcode or street-number pattern as a defence-in-depth measure.

Step 5: Build the analytics aggregation job without per-person coordinates

For each analytics report that currently uses individual coordinates or addresses, rewrite it to use the Boundaries endpoint to resolve to area codes at batch time, then aggregate before writing to the analytics store. The test: no row in your analytics store should contain a field that, combined with other fields in that row, could identify a specific individual's home address.

Step 6: Document and review with your DPO

Produce the data-flow description from the "Documenting the flow for your DPO" section above, tailored to your specific retained fields and retention periods. Walk through it with your DPO before declaring the migration complete. The engineering pattern is the foundation; the legal review is the sign-off.

Cost and scale

CSV2GEO covers 504M+ addresses across 63 countries, which includes comprehensive coverage of EU member states and the EEA. For most EU-market applications, a significant majority of submitted addresses will geocode cleanly on the first call.

The free tier allows 3,000 calls per day — sufficient for a pilot covering new-user signups at a mid-sized product. Paid tiers start at $54/month for 100,000 calls; the current pricing is at csv2geo.com/pricing/api.

For a product with 10,000 new user signups per month, that is 10,000 geocode calls per month — well within the entry paid tier, and the Boundaries calls for analytics aggregation are a small multiple on top of that, depending on how frequently you run the aggregation job. The cost of the geocoding pipeline is negligible compared to the cost of a Subject Access Request that requires trawling through a dozen data stores for a raw address string.

Cache the geocode results aggressively — coordinates do not change once a geocode is confirmed, and re-geocoding the same address on every login or every order is wasted spend and wasted processing. See Caching Geocoding Results — 90% Cost Reduction for the caching pattern. For this use case, the cache key should be the user or address identifier — not the raw address string, which you are no longer storing.

For pipeline reliability, make your geocode calls idempotent. If a network timeout means you retry the call, you should not risk creating a duplicate geocode record or, worse, a partial write where the lat is stored but the country_code is not. See Idempotent Geocoding — Safe to Retry for the retry and deduplication pattern.

What this pattern does not solve

Honest scope. The minimal-retention geocoding pattern handles one slice of your GDPR surface area: the address string at the point of geocoding. It does not address:

  • Consent and lawful basis. You still need a valid lawful basis for collecting the address in the first place. Data minimisation operates after the lawful-basis question is settled, not instead of it.
  • Addresses that legitimately need to be stored in full. If your use case requires delivering physical post to a person, you may need to retain a full address. The pattern still helps — geocode at intake to get coordinates for routing analytics, but acknowledge that the full address is separately retained for the postal-delivery purpose and document that retention separately.
  • Data already in your warehouse. If you have been storing raw addresses for three years, this pattern governs new data from the migration cutover forward. Remediating historical data is a separate project with its own legal considerations.
  • Cross-border data transfers. Where your geocoding API calls transit data to servers in a jurisdiction outside the EEA, this may have transfer-mechanism implications. That is a legal question. Talk to your DPO.
  • Any claim about CSV2GEO's own compliance posture. This post makes no claim that CSV2GEO is GDPR-certified, that it has signed a DPA with you, that its infrastructure is located in any particular jurisdiction, or that using it satisfies any specific obligation. Those are questions for your procurement and legal process.

Frequently Asked Questions

Does this post mean CSV2GEO is GDPR-compliant? No. This post describes an engineering pattern that minimises personal-data retention in your own systems. It makes no claims about CSV2GEO's compliance posture, infrastructure location, data-residency guarantees, or DPA status. Review those questions through your standard vendor-assessment process and with your legal counsel.

Do I need to include CSV2GEO in my Records of Processing Activities? Potentially, if you are sending personal data (raw addresses linked to identifiable individuals) to the geocoding API. That is a question for your DPO. The engineering pattern in this post is designed to make the assessment straightforward — the data sent is the raw address, the data returned and retained is the coordinate, and the raw address is not stored downstream.

What confidence threshold should I use for EU addresses? There is no universal answer — it depends on your use case and the consequences of a wrong geocode. A delivery-routing application might use 0.85; a tax-jurisdiction application might use 0.90; an analytics aggregation might accept 0.70. See Geocoding Confidence Scores Explained for how to think through the threshold decision. Whatever you choose, document it in your data-flow description so your DPO knows what happens to low-confidence addresses.

Can I cache the geocode result on the user's record and avoid re-calling the API on every session? Yes — this is the correct pattern. Cache the lat, lng, confidence, country_code, and postcode_district on the user record at intake. Re-geocode only if the user updates their address. There is no reason to call the geocoding API on every login or every page load. Caching coordinates is not a data-minimisation concern — coordinates are the minimal artefact you are retaining. The concern is not re-introducing the raw address string into the cache.

How do I handle a Subject Access Request if I am only storing coordinates? The coordinates you store are still personal data if they can be combined with other information to identify an individual (which they generally can, for a home address). You respond to the SAR by providing the coordinates and the derived fields (country_code, postcode_district) that you hold. You do not need to reverse-geocode the coordinates back to a street address for the SAR response — you are not obliged to reconstruct data you deliberately did not retain. Confirm this approach with your legal counsel.

What should I log if I need to debug a geocoding failure for a specific user without logging their address? Log a request UUID that you store on the geocode attempt record, linked to the user_id. When debugging, you look up the request UUID by user_id, then query your geocoding API access logs (which are your own server logs, not the provider's) by that UUID. The raw address is never in the log — it was only in the HTTP request body. If you need to reproduce the failure, ask the user to re-submit via a support flow; do not retain the original string for debugging purposes.

Does the Boundaries endpoint send coordinates as personal data? Coordinates sent to the Boundaries endpoint for area-level resolution may or may not constitute personal data depending on their precision and context — a coordinate rounded to 2 decimal places resolves to a ~1 km grid square and is generally not directly identifying; a full-precision coordinate for a residential address is more likely to be treated as personal data. Use the minimum precision required for the boundary resolution. Again, talk to your DPO about your specific situation.

Related Articles

---

*I.A. / CSV2GEO Creator*

Ready to geocode your addresses?

Use our batch geocoding tool to convert thousands of addresses to coordinates in minutes. Start with 100 free addresses.

Try Batch Geocoding Free →