Skip to content

Guide

Geocode a CSV of addresses (Python and Node.js)

Batch-geocode a spreadsheet of addresses with the geolith API in Python or Node.js, with a few parallel workers, retries on HTTP 429, and a quality column so you can review weak matches. Results may be stored permanently.

By · 15 min · updated

To geocode a CSV of addresses with geolith, call GET https://api.geolith.app/v1/search?text=<address> once per row, keep the first result's coordinates, and write them back with two quality columns: the layer it matched (address, venue, street, locality, …) and the confidence. The scripts below do exactly that in Python and in Node.js, with four parallel workers and retries. Results may be stored permanently, so you geocode each address once.

Input

addresses.csv:

id,address
1,Eiffel Tower
2,"Unter den Linden, Berlin"
3,東京タワー
4,"Connaught Place, New Delhi"

Export a secret key (gc_sk_..., server use only):

export GEOLITH_API_KEY=YOUR_API_KEY

Python

Needs Python 3.9+ and requests (pip install requests).

# geocode_csv.py — usage: python geocode_csv.py addresses.csv geocoded.csv
import csv
import os
import sys
import time
from concurrent.futures import ThreadPoolExecutor

import requests

API = "https://api.geolith.app/v1/search"
KEY = os.environ["GEOLITH_API_KEY"]
COUNTRY = None  # or an ISO code such as "IN" to search one country
WORKERS = 4

session = requests.Session()
session.headers["Authorization"] = f"Bearer {KEY}"


def geocode(address: str) -> dict:
    params = {"text": address, "size": 1}
    if COUNTRY:
        params["boundary.country"] = COUNTRY
    for attempt in range(6):
        res = session.get(API, params=params, timeout=15)
        if res.status_code == 429:
            code = res.json().get("error", {}).get("code")
            if code == "quota_exceeded":
                raise SystemExit("Monthly quota used up; upgrade or wait for the next month.")
            time.sleep(2 ** attempt)  # rate_limited: back off and retry
            continue
        res.raise_for_status()
        features = res.json()["features"]
        if not features:
            return {"lat": "", "lon": "", "label": "", "matched_layer": "", "confidence": "", "gid": ""}
        f = features[0]
        lon, lat = f["geometry"]["coordinates"]
        p = f["properties"]
        return {
            "lat": lat,
            "lon": lon,
            "label": p.get("label", ""),
            "matched_layer": p.get("layer", ""),
            "confidence": p.get("confidence", ""),
            "gid": p.get("gid", ""),
        }
    raise RuntimeError(f"gave up after retries: {address}")


def main(src: str, dst: str) -> None:
    with open(src, newline="", encoding="utf-8") as f:
        rows = list(csv.DictReader(f))

    with ThreadPoolExecutor(max_workers=WORKERS) as pool:
        results = list(pool.map(lambda r: geocode(r["address"]), rows))

    fields = list(rows[0].keys()) + ["lat", "lon", "label", "matched_layer", "confidence", "gid"]
    with open(dst, "w", newline="", encoding="utf-8") as f:
        w = csv.DictWriter(f, fieldnames=fields)
        w.writeheader()
        for row, result in zip(rows, results):
            w.writerow({**row, **result})
    print(f"Geocoded {len(rows)} rows -> {dst}")
    print("Data © OpenStreetMap contributors (ODbL), Overture Maps Foundation")


if __name__ == "__main__":
    main(sys.argv[1], sys.argv[2])

Node.js

Needs Node 18+ and two small packages: npm install csv-parse csv-stringify.

// geocode-csv.mjs — usage: node geocode-csv.mjs addresses.csv geocoded.csv
import { readFile, writeFile } from 'node:fs/promises';
import { parse } from 'csv-parse/sync';
import { stringify } from 'csv-stringify/sync';

const API = 'https://api.geolith.app/v1/search';
const KEY = process.env.GEOLITH_API_KEY;
const COUNTRY = ''; // or an ISO code such as 'IN' to search one country
const WORKERS = 4;
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));

async function geocode(address) {
  const url = new URL(API);
  url.searchParams.set('text', address);
  url.searchParams.set('size', '1');
  if (COUNTRY) url.searchParams.set('boundary.country', COUNTRY);

  for (let attempt = 0; attempt < 6; attempt++) {
    const res = await fetch(url, { headers: { Authorization: `Bearer ${KEY}` } });
    if (res.status === 429) {
      const body = await res.json();
      if (body.error?.code === 'quota_exceeded') throw new Error('Monthly quota used up');
      await sleep(1000 * 2 ** attempt); // rate_limited: back off and retry
      continue;
    }
    if (!res.ok) throw new Error(`geolith ${res.status}: ${await res.text()}`);
    const { features } = await res.json();
    const f = features[0];
    if (!f) return { lat: '', lon: '', label: '', matched_layer: '', confidence: '', gid: '' };
    const [lon, lat] = f.geometry.coordinates;
    return {
      lat,
      lon,
      label: f.properties.label,
      matched_layer: f.properties.layer,
      confidence: f.properties.confidence ?? '',
      gid: f.properties.gid,
    };
  }
  throw new Error(`gave up after retries: ${address}`);
}

const [src, dst] = process.argv.slice(2);
const rows = parse(await readFile(src, 'utf8'), { columns: true, skip_empty_lines: true });
const results = new Array(rows.length);

// A small worker pool: WORKERS requests in flight at a time.
let next = 0;
await Promise.all(
  Array.from({ length: WORKERS }, async () => {
    while (next < rows.length) {
      const i = next++;
      results[i] = { ...rows[i], ...(await geocode(rows[i].address)) };
    }
  }),
);

await writeFile(dst, stringify(results, { header: true }));
console.log(`Geocoded ${rows.length} rows -> ${dst}`);
console.log('Data © OpenStreetMap contributors (ODbL), Overture Maps Foundation');

Reading the output

id,address,lat,lon,label,matched_layer,confidence,gid
1,Eiffel Tower,48.858262,2.294496,"Tour Eiffel, Paris 7e Arrondissement, Paris, Île-de-France, France",venue,1,geolith:venue:4611686018432401268
2,"Unter den Linden, Berlin",52.517184,13.390903,"Unter den Linden, Dorotheenstadt, Mitte, Berlin, Deutschland",venue,1,geolith:venue:13998028899
3,東京タワー,35.658564,139.74544,"東京タワー, 芝公園四丁目, 港区, 東京都, 日本",venue,1,geolith:venue:-9223372036850528496
4,"Connaught Place, New Delhi",28.63177,77.21938,"Connaught Place, New Delhi, Delhi, India",locality,1,geolith:locality:561299050
  • matched_layer tells you how specific the match is. address and venue are usually right (row 4 matched the Connaught Place locality, which is what was asked); locality, county or region mean only the town or state matched. Review those rows.
  • If your CSV already splits the address into columns, call /v1/search/structured with address, locality, region, postalcode and country instead of text.
  • gid lets you fetch the same place again later with /v1/place?ids=.

Cost

Coverage is worldwide. One request per row. The Free plan's 100,000 requests a month cover 100,000 rows; Growth covers 7,500,000 for $99. Over quota the API answers HTTP 429 quota_exceeded and both scripts stop rather than run up a bill.

Questions

Quick answers

Is there a batch endpoint?
No. Send one /v1/search request per row. Four workers in parallel is a good start; on HTTP 429 the scripts wait and retry.
How many rows can I geocode for free?
The Free plan includes 100,000 requests a month, so up to 100,000 rows. Starter covers 1,000,000 for $19 a month and Scale 25,000,000 for $299.
May I keep the geocoded CSV?
Yes. Results may be stored permanently on every plan. Keep the attribution with the data, and if you publish a substantial extract as a database, the ODbL share-alike terms apply to it.

Geocode your first address in a minute.

100,000 requests a month free. Commercial use with attribution. No card.