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_layertells you how specific the match is.addressandvenueare usually right (row 4 matched the Connaught Place locality, which is what was asked);locality,countyorregionmean only the town or state matched. Review those rows.- If your CSV already splits the address into columns, call
/v1/search/structuredwithaddress,locality,region,postalcodeandcountryinstead oftext. gidlets 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.