# 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.

---
title: Geocode a CSV of addresses (Python and Node.js)
description: 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.
date: '2026-10-22'
tags: [python, node, csv, batch geocoding]
minutes: 15
steps:
  - name: Prepare the CSV
    text: One row per address with an id column and an address column. Extra columns are copied through.
  - name: Get a secret key
    text: Create a secret key (gc_sk_...) at console.geolith.app and export it as GEOLITH_API_KEY.
  - name: Run the script
    text: The Python or Node.js script calls /v1/search once per row with 4 parallel workers and retries HTTP 429 with backoff.
  - name: Review the quality columns
    text: Sort by matched_layer and confidence. Rows that matched only a locality or region need a manual look.
faqs:
  - q: Is there a batch endpoint?
    a: No. Send one /v1/search request per row. Four workers in parallel is a good start; on HTTP 429 the scripts wait and retry.
  - q: How many rows can I geocode for free?
    a: 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.
  - q: May I keep the geocoded CSV?
    a: 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.
---

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`:

```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):

```bash
export GEOLITH_API_KEY=YOUR_API_KEY
```

## Python

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

```python
# 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`.

```js
// 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

```csv
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.


Get a free API key: https://console.geolith.app/signup
