Skip to main content
Bulk adresvalidatie met de Geocoding API: 10.000 adressen in één keer valideren
Tutorials

Bulk adresvalidatie met de Geocoding API: 10.000 adressen in één keer valideren

Bulk adresvalidatie in Python met de MapAtlas Geocoding API: rate limiting, retries, confidence scoring en EU-adresformaten voor 10.000+ records.

Brent van der Heiden11 min read
#bulk geocoding#geocoding api#address validation#python geocoding#batch geocoding#address cleaning

Elke organisatie die al een paar jaar adresgegevens verzamelt, heeft hetzelfde probleem: een grote, rommelige adrestabel met onbekende kwaliteit. Adressen ingevoerd via webformulieren door gebruikers die niet opletten. Records gemigreerd vanuit een verouderd CRM dat invoer niet valideerde. Bulkimports vanuit de Excel van een partner in een incompatibel formaat. Bedrijfslocaties die zijn verplaatst nadat de data werd verzameld.

Logistiek draaien op slechte adresdata veroorzaakt mislukte afleveringen. Marketing erop draaien verspilt direct mail-budgetten. Vastgoedwaarderingen erop uitvoeren produceert onjuiste verzorgingsgebiedanalyses. Voordat een van die operaties plaatsvindt, moeten de adressen worden opgeschoond en gevalideerd.

Geocodering is de meest effectieve manier om dit op schaal te doen. Stuur een onbewerkte adresstring naar de Geocoding API; ontvang gestructureerde onderdelen, geografische coördinaten en een betrouwbaarheidsscore terug. Adressen die met hoge betrouwbaarheid geocoderen zijn vrijwel zeker geldig. Adressen die een lage betrouwbaarheid of helemaal geen overeenkomst retourneren, hebben menselijke controle nodig.

Deze tutorial bouwt een volledig Python-script dat een CSV met adressen leest, elk adres geocodeert via de MapAtlas Geocoding API, de resultaten met betrouwbaarheidsscores schrijft en records met lage betrouwbaarheid markeert voor handmatige controle. Het handelt snelheidsbeperkingen, voorbijgaande netwerkfouten en EU-adresformaatbijzonderheden af die tutorials gericht op de Amerikaanse markt missen.

Waarom adresdata verslechtert

Adresskwaliteit verslechtert om voorspelbare redenen:

Handmatige invoerfouten. Gebruikers van webformulieren typen snel, autocorrectie verminkt straatnamen en validatie die elke niet-lege string accepteert laat rommel door. In de grootschalige checkouttests van het Baymard Institute bevatte 9% van de handmatig ingevoerde verzendadressen typ- of spelfouten.

Bedrijfsverplaatsingen. Bedrijven verhuizen, fuseren en sluiten voortdurend. Eurostat zet het opheffingspercentage van ondernemingen in de EU op 8,5% in 2023, verhuizingen nog niet meegeteld. Een B2B-database van twee jaar geleden bevat dus een flink deel adressen waar geen actief bedrijf meer zit.

Formaatinconsistenties. Data verzameld uit meerdere bronnen gebruikt verschillende conventies: volledige landnamen versus ISO-codes, "St." versus "Straat", huisnummer voor of na de straatnaam, "flat" versus "apt" versus "wohnung". Een geocoder normaliseert dit alles naar gestructureerde uitvoer.

Migraties van verouderde systemen. Adresvelden die over meerdere databasekolommen waren verspreid, worden bij migratie vaak samengevoegd, waarbij structuur verloren gaat. Vrije-tekst adresvelden uit oudere systemen kunnen notities, referenties of opmaak bevatten die geen deel uitmaken van het werkelijke adres.

De geocoderingsbenadering handelt dit alles af omdat het vertrouwt op geografische overeenkomsten, niet op stringovereenkomsten. Een onjuist opgemaakt adres kan toch correct worden geocodeerd als de onderliggende locatiedata overeenkomt.

Betrouwbaarheidsscores begrijpen

De MapAtlas Geocoding API retourneert een confidence-eigenschap bij elk resultaat, variërend van 0,0 tot 1,0. Dit geeft aan hoe nauw het geretourneerde resultaat overeenkomt met de invoerzoekopdracht, rekening houdend met formaatafwijkingen, afkortingen en dubbelzinnigheid.

BetrouwbaarheidInterpretatieAanbevolen actie
0,90 tot 1,00Exacte of bijna exacte overeenkomstAutomatisch accepteren
0,85 tot 0,89Sterke overeenkomst, kleine formaatafwijkingenAccepteren met logging
0,60 tot 0,84Gedeeltelijke overeenkomst, straat gevonden maar huisnummer onzekerMarkeren voor handmatige controle
0,40 tot 0,59Dubbelzinnig, lokaalheid komt overeen maar niet specifiek adresWeigeren of escaleren
0,00 tot 0,39Geen betekenisvolle overeenkomstWeigeren, waarschijnlijk ongeldig

Dit zijn startpunten. Voor een logistieke operatie waarbij mislukte afleveringen duur zijn, verstreng de drempel voor automatisch accepteren naar 0,92 of hoger. Voor een marketingmailinglijst waarbij de kosten van een handmatige controle opwegen tegen een paar slechte adressen, verruim het naar 0,80.

De API retourneert ook een match_type-eigenschap: exact (het volledige adres is gevonden), interpolated (positie geschat tussen bekende huisnummers) of fallback (het volledige adres is niet gevonden en een breder niveau kwam in plaats daarvan overeen). De eigenschap layer vertelt welk niveau dat was, bijvoorbeeld address, street of locality, en accuracy geeft aan of het punt een exact point of een centroid is.

Het Python-validatiescript

Installeer afhankelijkheden:

pip install requests pandas tqdm

Het script leest een CSV met een kolom address (of afzonderlijke kolommen street, city, country), geocodeert elke rij en schrijft een nieuwe CSV met toegevoegde validatieresultaten.

#!/usr/bin/env python3
"""
bulk_geocode.py, Validate a CSV of addresses using the MapAtlas Geocoding API.

Input CSV must have either:
  - An 'address' column (full address string), or
  - 'street', 'city', and 'country' columns (will be concatenated)

Outputs a new CSV with added columns:
  geocoded_label, geocoded_lat, geocoded_lng, confidence, match_type, status
"""

import csv
import time
import logging
import requests
import pandas as pd
from tqdm import tqdm
from pathlib import Path

# ── Configuration ──────────────────────────────────────────────────────────────
API_KEY         = 'YOUR_API_KEY'
API_BASE        = 'https://gateway.mapmetrics-atlas.net/forward-geocode/'
INPUT_CSV       = 'addresses.csv'
OUTPUT_CSV      = 'addresses_validated.csv'
RATE_LIMIT_RPS  = 5          # Requests per second (stay within your plan limits)
RETRY_ATTEMPTS  = 3          # Retries on transient errors
RETRY_DELAY_S   = 2.0        # Seconds between retries

# Confidence thresholds
ACCEPT_THRESHOLD = 0.85
REVIEW_THRESHOLD = 0.60

logging.basicConfig(level=logging.INFO, format='%(levelname)s %(message)s')
log = logging.getLogger(__name__)

# ── Geocoding function ─────────────────────────────────────────────────────────
def geocode_address(address_str: str) -> dict:
    """
    Geocode a single address string. Returns a dict with result fields.
    Retries on network errors and 429 rate-limit responses.
    """
    params = {
        'text': address_str,
        'token': API_KEY,
        'size': 1,
    }

    for attempt in range(RETRY_ATTEMPTS):
        try:
            resp = requests.get(API_BASE, params=params, timeout=10)

            if resp.status_code == 429:
                # Rate limited, wait and retry
                wait = float(resp.headers.get('Retry-After', RETRY_DELAY_S * (attempt + 1)))
                log.warning(f'Rate limited. Waiting {wait:.1f}s before retry {attempt + 1}.')
                time.sleep(wait)
                continue

            resp.raise_for_status()
            data = resp.json()

            if not data.get('features'):
                return {'geocoded_label': '', 'geocoded_lat': None, 'geocoded_lng': None,
                        'confidence': 0.0, 'match_type': 'no_match', 'status': 'reject'}

            feature    = data['features'][0]
            props      = feature['properties']
            coords     = feature['geometry']['coordinates']  # [lng, lat]
            confidence = round(float(props.get('confidence', 0.0)), 4)
            match_type = props.get('match_type', 'unknown')

            if confidence >= ACCEPT_THRESHOLD:
                status = 'accept'
            elif confidence >= REVIEW_THRESHOLD:
                status = 'review'
            else:
                status = 'reject'

            return {
                'geocoded_label': props.get('label', ''),
                'geocoded_lat':   round(coords[1], 6),
                'geocoded_lng':   round(coords[0], 6),
                'confidence':     confidence,
                'match_type':     match_type,
                'status':         status,
            }

        except requests.exceptions.RequestException as exc:
            log.warning(f'Network error on attempt {attempt + 1}: {exc}')
            if attempt < RETRY_ATTEMPTS - 1:
                time.sleep(RETRY_DELAY_S * (attempt + 1))

    # All retries exhausted
    return {'geocoded_label': '', 'geocoded_lat': None, 'geocoded_lng': None,
            'confidence': 0.0, 'match_type': 'error', 'status': 'error'}


# ── Main processing loop ───────────────────────────────────────────────────────
def main():
    df = pd.read_csv(INPUT_CSV, dtype=str).fillna('')

    # Build address string from available columns
    if 'address' in df.columns:
        df['_query'] = df['address']
    elif all(c in df.columns for c in ['street', 'city', 'country']):
        df['_query'] = df['street'] + ', ' + df['city'] + ', ' + df['country']
    else:
        raise ValueError("CSV must have 'address' or 'street'+'city'+'country' columns.")

    results = []
    sleep_interval = 1.0 / RATE_LIMIT_RPS

    for query in tqdm(df['_query'], desc='Geocoding', unit='addr'):
        result = geocode_address(query.strip())
        results.append(result)
        time.sleep(sleep_interval)

    results_df = pd.DataFrame(results)
    output_df  = pd.concat([df.drop(columns=['_query']), results_df], axis=1)
    output_df.to_csv(OUTPUT_CSV, index=False, quoting=csv.QUOTE_NONNUMERIC)

    # Summary
    total   = len(output_df)
    accept  = (output_df['status'] == 'accept').sum()
    review  = (output_df['status'] == 'review').sum()
    reject  = (output_df['status'] == 'reject').sum()
    errors  = (output_df['status'] == 'error').sum()

    log.info(f'\n── Results ────────────────────────────────')
    log.info(f'Total processed : {total:,}')
    log.info(f'Accept (≥{ACCEPT_THRESHOLD}) : {accept:,}  ({accept/total:.1%})')
    log.info(f'Review           : {review:,}  ({review/total:.1%})')
    log.info(f'Reject           : {reject:,}  ({reject/total:.1%})')
    log.info(f'Errors           : {errors:,}  ({errors/total:.1%})')
    log.info(f'Output written to {OUTPUT_CSV}')


if __name__ == '__main__':
    main()

Voer het uit:

python bulk_geocode.py

Voor een CSV met 10.000 adressen bij 5 verzoeken per seconde heeft het script minstens 33 minuten wachttijd voor de rate limit nodig plus de latentie per verzoek, in totaal meestal 35 tot 50 minuten. De voortgangsbalk (via tqdm) toont real-time doorvoer en geschatte voltooiingstijd.

De uitvoer verwerken

De uitvoer-CSV voegt zes kolommen toe aan je invoergegevens:

address, ..., geocoded_label, geocoded_lat, geocoded_lng, confidence, match_type, status

Filter op status om drie uitvoerbestanden te genereren:

# After running main(), split into acceptance tiers:
df = pd.read_csv('addresses_validated.csv')

df[df['status'] == 'accept'].to_csv('addresses_clean.csv',  index=False)
df[df['status'] == 'review'].to_csv('addresses_review.csv', index=False)
df[df['status'] == 'reject'].to_csv('addresses_reject.csv', index=False)

print(f"Clean:  {len(df[df['status']=='accept']):,} addresses ready for use")
print(f"Review: {len(df[df['status']=='review']):,} addresses for manual check")
print(f"Reject: {len(df[df['status']=='reject']):,} addresses to discard or fix")

Het bestand addresses_review.csv vereist menselijke aandacht. Typische controlepatronen:

  • Fallback naar layer street (betrouwbaarheid 0,65 tot 0,80): De straat werd gevonden maar het huisnummer niet. Waarschijnlijk een nieuw gebouw, een landelijk adres met beperkte dekking, of een typfout in het huisnummer. Controleer de oorspronkelijke bron.
  • Fallback naar layer locality (betrouwbaarheid 0,50 tot 0,65): Alleen de stad komt overeen. De straatnaam is waarschijnlijk fout gespeld of bestaat niet in de data. Zoek het adres op in een postadressengids.
  • Lage betrouwbaarheid bij duidelijk geldige adressen: Controleer op een mismatch tussen land en taal. Een Nederlands adres zonder landbeperking kan worden gecodeeerd naar een gelijknamige Duitse of Belgische stad.

EU-adresformaatbijzonderheden om te kennen

Bulk geocodeeringgidsen geschreven voor de Amerikaanse markt slaan de formaatafwijkingen over die EU-datasets problemen geven. Dit zijn de meest voorkomende problemen:

Duitsland, huisnummervolgorde. Duitse adressen gebruiken het formaat {straat} {nummer}: Berliner Straße 42. Veel CRM's slaan adressen op in het formaat {nummer} {straat} omdat ze zijn gebouwd voor de Britse of Amerikaanse conventie. Als je Duitse adressen met lage betrouwbaarheid geocoderen, probeer dan het nummer en de straatnaam in de zoekopdracht om te draaien voordat je ze indient.

Frankrijk, arrondissementen. Parijse adressen bevatten een arrondissement (1e tot 20e) als onderdeel van de postcode: 75001 tot 75020. Zoekopdrachten die het arrondissement weglaten en gewoon Paris gebruiken, worden geocodeerd naar het stadscentrum, niet naar het specifieke district. Dit verschijnt als een locality-overeenkomst met lage betrouwbaarheid in plaats van een address-overeenkomst.

Nederland, postcodeformaat. Nederlandse postcodes volgen een strikt DDDD LL-patroon (4 cijfers, spatie, 2 hoofdletters). Codes opgeslagen zonder spatie (1012LG in plaats van 1012 LG) of met kleine letters worden correct geocodeerd, maar als je het postcodeformaat afzonderlijk valideert, normaliseer naar hoofdletters met een spatie.

België, taalambiguïteit. Sommige Belgische gemeenten hebben verschillende Franse en Nederlandse namen (Luik/Liège, Gent/Gand). De API verwerkt beide, maar inconsistente naamgeving in je dataset (sommige records in het Frans, sommige in het Nederlands) kan verschillende betrouwbaarheidsniveaus produceren. Normaliseer naar één taal per regio voordat je geocodeert.

Spanje en Italië, variaties in straatprefixen. "Calle", "Carrer", "Via", "Viale" zijn allemaal geldige straattype-prefixen en kunnen afgekort verschijnen ("C/") in records. De geocoder verwerkt veelgebruikte afkortingen, maar ongebruikelijke afkortingen uit verouderde systemen kunnen een normalisatiestap vereisen.

Doorvoer verhogen met gelijktijdige verzoeken

Het sequentiële script is veilig en eenvoudig maar traag. Voor grotere datasets vervang je de sequentiële lus door een gelijktijdige uitvoerder:

from concurrent.futures import ThreadPoolExecutor, as_completed

def main_concurrent(max_workers=10):
    df = pd.read_csv(INPUT_CSV, dtype=str).fillna('')
    # ... (same setup as before) ...

    results = [None] * len(df)

    with ThreadPoolExecutor(max_workers=max_workers) as executor:
        future_to_idx = {
            executor.submit(geocode_address, row['_query'].strip()): idx
            for idx, row in df.iterrows()
        }
        for future in tqdm(as_completed(future_to_idx), total=len(df), desc='Geocoding'):
            idx = future_to_idx[future]
            results[idx] = future.result()

    # ... (same output writing as before) ...

Met 10 gelijktijdige workers wordt de doorvoer begrensd door de API-latentie en de rate limit van je abonnement in plaats van door een wachtpauze: bij 50 verzoeken per seconde zijn 10.000 adressen in minder dan 4 minuten klaar. De gelijktijdige versie heeft geen ingebouwde throttle, dus voeg er een toe of verlaag max_workers als je abonnement het aantal verzoeken per seconde begrenst. Controleer de limiet voor gelijktijdige verzoeken van je MapAtlas-abonnement voordat je max_workers verhoogt.

Voor organisaties die adresvalidatie als terugkerende operatie uitvoeren, nieuwe CRM-imports wekelijks opschonen of afleveradressen 's nachts valideren, zie de pagina Logistiek en Bezorging voor informatie over hoe MapAtlas-integraties passen in operationele workflows.

Als je adresautoaanvulling bouwt om slechte data van tevoren te voorkomen (fouten bij de bron opvangen in plaats van ze in bulk op te schonen), behandelt Adres-autocomplete-API: hoe één veld de checkoutconversie verhoogt de frontend-implementatie.

Werken met de coördinatenuitvoer

De gevalideerde uitvoer bevat geocoded_lat en geocoded_lng voor elk geaccepteerd adres. Deze coördinaten openen analysemogelijkheden die niet mogelijk waren met onbewerkte adresstrings:

  • Afstandsberekeningen. Berekenen van rechte-lijnafstanden tussen een magazijn en elk afleveradres om verzendkostentiers te schatten.
  • Verzorgingsgebiedanalyse. Gevalideerde klantlocaties op een kaart uitzetten om te zien waar de vraag geografisch clustert.
  • Bezorgzoneassignatie. Elk adres toewijzen aan een bezorgzone door te controleren of de coördinaten binnen een zonepolygoon vallen.
  • Duplicaatdetectie. Twee records met dezelfde coördinaten (binnen een paar meter) zijn waarschijnlijk duplicaten, zelfs als de adresstrings in opmaak verschillen.

Voor NAP-consistentie (naam, adres, telefoon) en de impact ervan op AI-zoekzichtbaarheid, in het bijzonder relevant voor lokale bedrijfsadresbases, legt NAP-consistentie voor AI-zoekopdrachten: waarom niet-overeenkomende adressen je ChatGPT-zichtbaarheid vernielen uit waarom geocodeervalideerde adressen de juiste basis zijn voor gestructureerde data-opmaak.

Samenvatting

Bulk geocoderen met de MapAtlas Geocoding API geeft je:

  • Gevalideerde adressen met betrouwbaarheidsscores zodat je weet welke records je automatisch kunt vertrouwen en welke controle nodig hebben.
  • Gestructureerde onderdelen (straat, huisnummer, postcode, stad, land) genormaliseerd vanuit elk invoerformaat.
  • Geografische coördinaten voor elk geaccepteerd adres, die ruimtelijke analyses en routering mogelijk maken.
  • Ondersteuning voor EU-adresformaten ingebouwd in de API, niet iets wat je in voorverwerking hoeft af te handelen.

Het Python-script draait bij 5 adressen per seconde sequentieel, tot 50 per seconde met gelijktijdige workers. Reken voor 10.000 adressen op 4 tot 50 minuten, afhankelijk van gelijktijdigheid en latentie. Uitvoer is een nette CSV met drie niveaus: accepteren, controleren, weigeren.


Meld je aan voor een gratis MapAtlas API-sleutel om te beginnen. De Geocoding API ondersteunt bulkzoekopdrachten op alle abonnementen, zie prijzen voor tarieven per verzoek en maandelijkse gratis limieten.

Veelgestelde vragen

Hoe lang duurt het om 10.000 adressen te geocoderen met de MapAtlas API?

Bij 5 verzoeken per seconde kosten 10.000 adressen minstens 33 minuten wachttijd voor de rate limit, plus de netwerklatentie per verzoek, dus reken op 35 tot 50 minuten. Met 10 gelijktijdige workers op zo'n 50 verzoeken per seconde daalt dit tot minder dan 5 minuten, mits de rate limit van je abonnement dat toelaat. Het Python-script in deze tutorial bevat zowel sequentiële als gelijktijdige opties.

Welke betrouwbaarheidsdrempel moet ik gebruiken voor adresvalidatie?

Een praktisch drieniveausysteem werkt goed: accepteer adressen met een betrouwbaarheid boven 0,85, markeer voor handmatige controle tussen 0,60 en 0,85, en wijs af onder 0,60. De exacte drempelwaarden hangen af van hoe kritisch adresnauwkeurigheid is voor je gebruik, logistieke operaties moeten strengere drempelwaarden hanteren dan marketingcampagnes.

Verwerkt de MapAtlas Geocoding API EU-specifieke adresformaten?

Ja. De API verwerkt correct het Duitse (huisnummer na straatnaam), Franse (huisnummer voor straatnaam), Nederlandse (4+2 postcodes) en andere EU-landadresconventies. Zoekopdrachten in de lokale taal leveren betere resultaten op dan getranslitereerde of Engelstalige zoekopdrachten.

Was dit nuttig? Deel het.

Over de auteur

Brent van der Heiden

Geschreven door

Brent van der Heiden

Co-Founder & CEO at MapAtlas

Brent built MapAtlas out of a conviction that developers deserve location APIs with fair pricing and genuine end-user privacy. Before that he co-founded MapMetrics, a community mapping and navigation app. He writes about geospatial infrastructure, map data, AI search visibility, and how location data powers the products people rely on every day.

Alle artikelen bekijken →
Terug naar blog