Batch Geocoding
At ~100 ms per request, geocoding 10,000 addresses sequentially takes over 15 minutes. Running 20 requests in parallel cuts that to about a minute. Each example below uses the idiom most natural to its language:
| Language | Concurrency mechanism |
|---|---|
| Go | Buffered channel as semaphore + goroutines + sync.WaitGroup |
| JavaScript | Promise.allSettled over chunks of 20 |
| Python | asyncio.Semaphore(20) + asyncio.gather |
| Rust | stream::iter().buffer_unordered(20) |
// batch_geocode.go
// go mod init yourapp && go mod tidy
package main
import (
"encoding/json"
"fmt"
"net/http"
"net/url"
"os"
"sync"
)
const concurrency = 20
type GeoResult struct {
Lat string `json:"lat"`
Lon string `json:"lon"`
DisplayName string `json:"display_name"`
}
func geocode(client *http.Client, address string) (*GeoResult, error) {
u := "https://api.pickpoint.io/v2/geocode/forward?limit=1&q=" + url.QueryEscape(address)
req, _ := http.NewRequest("GET", u, nil)
req.Header.Set("X-Api-Key", os.Getenv("PICKPOINT_KEY"))
resp, err := client.Do(req)
if err != nil { return nil, err }
defer resp.Body.Close()
var results []GeoResult
json.NewDecoder(resp.Body).Decode(&results)
if len(results) == 0 { return nil, fmt.Errorf("no result") }
return &results[0], nil
}
func batchGeocode(addresses []string) []*GeoResult {
// Buffered channel works as a semaphore: at most 20 goroutines run at once
sem := make(chan struct{}, concurrency)
out := make([]*GeoResult, len(addresses))
var mu sync.Mutex
var wg sync.WaitGroup
client := &http.Client{}
for i, addr := range addresses {
wg.Add(1)
go func(i int, addr string) {
defer wg.Done()
sem <- struct{}{} // acquire
defer func() { <-sem }() // release
geo, err := geocode(client, addr)
if err != nil {
fmt.Fprintf(os.Stderr, "[%d] error: %v\n", i, err)
return
}
mu.Lock()
out[i] = geo
mu.Unlock()
}(i, addr)
}
wg.Wait()
return out
}
func main() {
addresses := []string{"Eiffel Tower, Paris", "Big Ben, London", "Colosseum, Rome"}
for i, g := range batchGeocode(addresses) {
if g != nil { fmt.Printf("[%d] %s, %s\n", i, g.Lat, g.Lon) }
}
}// batch-geocode.mjs (Node.js 18+)
import { writeFileSync } from 'fs';
const API_KEY = process.env.PICKPOINT_KEY;
const CONCURRENCY = 20;
async function geocode(address) {
const res = await fetch(
`https://api.pickpoint.io/v2/geocode/forward?q=${encodeURIComponent(address)}&limit=1`,
{ headers: { 'X-Api-Key': API_KEY } }
);
const data = await res.json();
return data[0] ?? null;
}
async function batchGeocode(addresses) {
const results = new Array(addresses.length).fill(null);
// Process in chunks of CONCURRENCY; within each chunk all run in parallel
for (let i = 0; i < addresses.length; i += CONCURRENCY) {
const chunk = addresses.slice(i, i + CONCURRENCY);
const settled = await Promise.allSettled(chunk.map(geocode));
settled.forEach((s, j) => {
if (s.status === 'fulfilled') results[i + j] = s.value;
else console.error(`[error] ${chunk[j]}: ${s.reason}`);
});
process.stdout.write(`\r${Math.min(i + CONCURRENCY, addresses.length)}/${addresses.length}`);
}
console.log('\nDone.');
return results;
}
// ── main ──
const addresses = ['Eiffel Tower, Paris', 'Big Ben, London', 'Colosseum, Rome'];
const geos = await batchGeocode(addresses);
writeFileSync('geocoded.json', JSON.stringify(
addresses.map((a, i) => ({ address: a, result: geos[i] })), null, 2
));# batch_geocode.py
# pip install httpx
import asyncio, json, os, sys
import httpx
API_KEY = os.environ["PICKPOINT_KEY"]
CONCURRENCY = 20
async def geocode(client: httpx.AsyncClient, sem: asyncio.Semaphore,
address: str) -> dict | None:
async with sem:
r = await client.get(
"https://api.pickpoint.io/v2/geocode/forward",
params={"q": address, "limit": 1},
headers={"X-Api-Key": API_KEY},
)
data = r.json()
return data[0] if data else None
async def batch_geocode(addresses: list[str]) -> list[dict | None]:
sem = asyncio.Semaphore(CONCURRENCY)
async with httpx.AsyncClient(timeout=15) as client:
tasks = [geocode(client, sem, a) for a in addresses]
return await asyncio.gather(*tasks, return_exceptions=True)
# ── main ──
if __name__ == "__main__":
addresses = ["Eiffel Tower, Paris", "Big Ben, London", "Colosseum, Rome"]
results = asyncio.run(batch_geocode(addresses))
print(json.dumps([{"address": a, "result": r}
for a, r in zip(addresses, results)], indent=2))// Cargo.toml:
// [dependencies]
// tokio = { version = "1", features = ["full"] }
// reqwest = { version = "0.12", features = ["json"] }
// futures = "0.3"
// serde = { version = "1", features = ["derive"] }
use futures::stream::{self, StreamExt};
use reqwest::Client;
use serde::Deserialize;
use std::env;
const CONCURRENCY: usize = 20;
#[derive(Debug, Deserialize)]
struct GeoResult { lat: String, lon: String, display_name: String }
async fn geocode(client: &Client, address: String) -> Option<GeoResult> {
let resp = client
.get("https://api.pickpoint.io/v2/geocode/forward")
.query(&[("q", &address), ("limit", &"1".to_string())])
.header("X-Api-Key", env::var("PICKPOINT_KEY").unwrap())
.send().await.ok()?;
resp.json::<Vec<GeoResult>>().await.ok()?.into_iter().next()
}
#[tokio::main]
async fn main() {
let addresses = vec!["Eiffel Tower, Paris", "Big Ben, London", "Colosseum, Rome"];
let client = Client::new();
// buffer_unordered(20) keeps exactly 20 futures running at all times
let results: Vec<_> = stream::iter(addresses.iter())
.map(|&addr| geocode(&client, addr.to_string()))
.buffer_unordered(CONCURRENCY)
.collect()
.await;
for (addr, geo) in addresses.iter().zip(&results) {
match geo {
Some(g) => println!("{} → {}, {}", addr, g.lat, g.lon),
None => eprintln!("no result: {}", addr),
}
}
}Throughput
With 20 concurrent workers at ~100 ms average latency: ~200 req/sec, ~720k/hour.
| Addresses | Estimated time |
|---|---|
| 1,000 | ~5 seconds |
| 10,000 | ~1 minute |
| 100,000 | ~8–10 minutes |
| 1,000,000 | ~90 minutes |
Tips
- Cache. Store coordinates in a database - geocoding output for a given address is stable. Never re-geocode the same address twice.
- Clean addresses first. Add city and country when missing. Vague queries like "Main Street" return the wrong place.
- Handle null results. Some addresses won't match. Log them for manual review or fall back to city-level geocoding.
- Back off on 429. If the API returns
429 Too Many Requests, wait and retry with exponential back-off. - Add countrycodes. Pass
countrycodes=us(or equivalent) if all addresses are in one country - fewer false matches.