Tutorial de Geocoding en Go: Goroutines, Concurrencia Acotada y Backoff
Tutorial Go de producción para geocoding: pools de goroutines, concurrencia con semáforo, exponential backoff en 429s. Compila y corre.
Go es el lenguaje al que recurrir cuando quieres concurrencia sin la ceremonia de async/await. Las goroutines son stacks de 2 KB, los channels son pipes tipados, y la biblioteca estándar net/http ya es suficientemente buena para producción desde el primer día. No hay event loop que aprender, ninguna palabra clave await que esparcir por cada función, y ningún problema de función coloreada. Escribes código en línea recta, y lo paralelizas con go y un channel-semáforo.
Este es un tutorial Go funcional para geocoding de direcciones vía REST API. Cada snippet fue escrito en un archivo .go, compilado con go build, y ejecutado contra la API real en una instalación limpia de Go 1.22 antes de publicar. No hay SDK, biblioteca HTTP de terceros ni framework — solo net/http, encoding/json, encoding/csv, y context. Al final tendrás un programa que hace streaming de un CSV de cualquier tamaño, geocoda sus filas con un pool configurable de goroutines, reintenta 429 y 5xx con exponential backoff con jitter que respeta Retry-After, y escribe un CSV {lat, lng, error} sin nunca mantener el archivo de entrada en memoria. Unas 80 líneas de Go.
El endpoint usado en todo el tutorial es csv2geo.com/api/v1. El free tier ofrece 1.000 peticiones forward/reverse por día más 100 filas de batch por día, sin tarjeta de crédito. Inicia sesión y consigue una clave en /api-keys para seguir.
El endpoint
Dos endpoints cubren casi todo trabajo de geocoding del mundo real. Forward convierte una string de dirección en coordenadas. Reverse convierte coordenadas en una dirección. Ambos aceptan GET (single) o POST (batch).
# Forward (single)
GET https://csv2geo.com/api/v1/geocode?q=ADDRESS&country=US
# Reverse (single)
GET https://csv2geo.com/api/v1/reverse?lat=LAT&lng=LNG
# Batch forward
POST https://csv2geo.com/api/v1/geocode
Body: { "addresses": ["addr1", "addr2", ...] }
# Auth: either ?api_key=KEY query string, or
# Authorization: Bearer KEY headerEl formato de la respuesta para una sola petición forward se ve así. Esta es salida real, no un ejemplo de documentación.
{
"query": "1600 Pennsylvania Ave NW Washington DC",
"results": [
{
"formatted_address": "1600 Pennsylvania Ave NW, Washington, DC 20500-0005, United States",
"location": { "lat": 38.89768, "lng": -77.03655 },
"accuracy": "houseNumber",
"accuracy_score": 1,
"components": {
"house_number": "1600",
"street": "Pennsylvania Ave NW",
"city": "Washington",
"state": "District of Columbia",
"postal_code": "20500-0005",
"country": "USA"
}
}
],
"meta": { "response_time_ms": 673, "source": "here" }
}Dos campos importan más. results[0].location es tu {lat, lng}. results[0].accuracy es el nivel del match — "houseNumber" es rooftop, "street" es centroide de calle, "place" es un match de POI, "postcode" es centroide de código postal. El accuracy_score numérico (0.0–1.0) da un threshold más fino; mira scores de confianza en geocoding explicados para elegir uno.
Primera petición
net/http más encoding/json es todo lo que necesitas. La primera versión devuelve solo las coordenadas para que podamos verificar el formato wire de extremo a extremo.
// geocode.go
package main
import (
"encoding/json"
"fmt"
"net/http"
"net/url"
"os"
"time"
)
const baseURL = "https://csv2geo.com/api/v1"
type Location struct {
Lat float64 `json:"lat"`
Lng float64 `json:"lng"`
}
type GeocodeResult struct {
FormattedAddress string `json:"formatted_address"`
Location Location `json:"location"`
Accuracy string `json:"accuracy"`
AccuracyScore float64 `json:"accuracy_score"`
}
type GeocodeResponse struct {
Query string `json:"query"`
Results []GeocodeResult `json:"results"`
}
var client = &http.Client{Timeout: 10 * time.Second}
func geocode(address, country string) (*Location, error) {
q := url.Values{}
q.Set("q", address)
if country != "" {
q.Set("country", country)
}
req, err := http.NewRequest("GET", baseURL+"/geocode?"+q.Encode(), nil)
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "Bearer "+os.Getenv("CSV2GEO_KEY"))
resp, err := client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode >= 300 {
return nil, fmt.Errorf("http %d", resp.StatusCode)
}
var out GeocodeResponse
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
return nil, err
}
if len(out.Results) == 0 {
return nil, nil
}
return &out.Results[0].Location, nil
}
func main() {
loc, err := geocode("1 Apple Park Way, Cupertino, CA", "US")
if err != nil {
fmt.Fprintln(os.Stderr, "error:", err)
os.Exit(1)
}
fmt.Printf("%+v\n", loc)
}Ejecútalo:
CSV2GEO_KEY=geo_live_xxx go run geocode.go
# &{Lat:37.33177 Lng:-122.03042}Algunas cosas que vale la pena señalar. defer resp.Body.Close() no es opcional — si lo olvidas, la conexión TCP subyacente no puede devolverse al pool y vas a filtrar file descriptors bajo carga. Usa un único http.Client a nivel de paquete en lugar de http.Get directamente; el cliente por defecto tiene Timeout: 0 — lo que significa que un servidor colgado puede bloquear tu goroutine para siempre. Usa Authorization: Bearer en vez del parámetro ?api_key= para que las claves nunca acaben en historial de shell o archivos de log.
Reverse geocoding
Mismo formato, parámetros distintos. Pasa lat y lng, recibe de vuelta una dirección.
type ReverseResult struct {
FormattedAddress string `json:"formatted_address"`
Location Location `json:"location"`
Accuracy string `json:"accuracy"`
DistanceMeters float64 `json:"distance_meters,omitempty"`
}
type ReverseResponse struct {
Results []ReverseResult `json:"results"`
}
func reverse(lat, lng float64) (*ReverseResult, error) {
q := url.Values{}
q.Set("lat", fmt.Sprintf("%f", lat))
q.Set("lng", fmt.Sprintf("%f", lng))
req, err := http.NewRequest("GET", baseURL+"/reverse?"+q.Encode(), nil)
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "Bearer "+os.Getenv("CSV2GEO_KEY"))
resp, err := client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode >= 300 {
return nil, fmt.Errorf("http %d", resp.StatusCode)
}
var out ReverseResponse
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
return nil, err
}
if len(out.Results) == 0 {
return nil, nil
}
return &out.Results[0], nil
}Las respuestas reverse incluyen un campo distance_meters — qué tan lejos está la dirección emparejada de la coordenada de entrada. Si estás haciendo reverse-geocoding de pings GPS de una app de delivery, cualquier cosa por encima de ~50m normalmente significa que el GPS estaba mal, no el geocoder. Bajo ~10m estás mirando el rooftop. La distancia ground-truth es la única métrica honesta de accuracy para reverse geocoding; todo lo demás miente un poco.
Concurrencia acotada al estilo Go
La forma incorrecta de geocodar 10.000 direcciones en Go es for _, a := range addrs { go geocode(a) }. Eso lanza 10.000 goroutines sin límite, abre 10.000 conexiones TCP, agota el rate limit de la API en la segunda iteración, y agota tus file descriptors locales al mismo tiempo. Las goroutines son baratas, pero no gratis, y la red nunca es gratis.
La forma correcta es concurrencia acotada con un channel-semáforo. Un channel buferizado de struct{} con capacidad N es el semáforo canónico de Go — enviar a él bloquea una vez que N goroutines ya están en vuelo.
// pool.go
package main
import (
"fmt"
"sync"
)
func geocodeMany(addresses []string, concurrency int) []*Location {
sem := make(chan struct{}, concurrency)
out := make([]*Location, len(addresses))
var wg sync.WaitGroup
for i, addr := range addresses {
wg.Add(1)
sem <- struct{}{} // acquire
go func(i int, addr string) {
defer wg.Done()
defer func() { <-sem }() // release
loc, err := geocode(addr, "US")
if err != nil {
fmt.Println("err:", addr, err)
return
}
out[i] = loc
}(i, addr)
}
wg.Wait()
return out
}Tres modismos en este código justifican su lugar. Primero, sem <- struct{}{} sucede *antes* de la palabra clave go, de modo que el loop for bloquea en el throttle en lugar de programar miles de goroutines pendientes. Segundo, defer func() { <-sem }() libera el slot incluso si el worker hace panic — una cadena de defer que nunca debes saltar. Tercero, out[i] = loc escribe en un índice *único* por goroutine, así que ningún mutex es necesario; las escrituras concurrentes a slots distintos de un slice son seguras en Go. Capturar i y addr como parámetros del literal de goroutine evita el bug de la variable de loop que mordió a todo dev de Go antes de 1.22.
Una regla inicial útil para concurrency: toma el rate limit por minuto de tu plan, divídelo entre 60, y apunta a aproximadamente ese número de peticiones in-flight. Free es 100/min — empieza en 4. Starter ($49, 1K/min) — 16. Growth ($149, 5K/min) — 64. Pro ($499, 10K/min) — 128 y observa los headers. El desglose completo está en concurrency tuning para geocoding.
Retry, backoff y el patrón de context
Tres cosas que todo geocoder de producción necesita: (1) tratar 429 y 5xx como retriables, (2) honrar el header Retry-After cuando el servidor manda uno, (3) limitar intentos para que una clave permanentemente muerta no entre en loop para siempre. Agrega context.Context para que el caller pueda cancelar una cadena de retry larga, y tienes una función segura para meter en un servicio real.
// retry.go
package main
import (
"context"
"encoding/json"
"fmt"
"io"
"math/rand"
"net/http"
"net/url"
"os"
"strconv"
"time"
)
type GeocodeError struct {
Status int
Message string
}
func (e *GeocodeError) Error() string {
return fmt.Sprintf("geocode: status=%d msg=%s", e.Status, e.Message)
}
func geocodeWithRetry(ctx context.Context, address, country string, maxAttempts int) (*GeocodeResponse, error) {
q := url.Values{}
q.Set("q", address)
if country != "" {
q.Set("country", country)
}
target := baseURL + "/geocode?" + q.Encode()
var lastErr error
for attempt := 1; attempt <= maxAttempts; attempt++ {
req, err := http.NewRequestWithContext(ctx, "GET", target, nil)
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "Bearer "+os.Getenv("CSV2GEO_KEY"))
resp, err := client.Do(req)
if err != nil {
// Network errors are retriable.
lastErr = err
if attempt == maxAttempts {
return nil, fmt.Errorf("network error after %d attempts: %w", attempt, err)
}
if err := sleep(ctx, backoff(attempt)); err != nil {
return nil, err
}
continue
}
if resp.StatusCode < 300 {
defer resp.Body.Close()
var out GeocodeResponse
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
return nil, err
}
return &out, nil
}
// Non-retriable: 4xx other than 429 means bad key, bad input, etc.
if resp.StatusCode != 429 && resp.StatusCode < 500 {
body, _ := io.ReadAll(resp.Body)
resp.Body.Close()
return nil, &GeocodeError{Status: resp.StatusCode, Message: string(body)}
}
// Retriable: 429 or 5xx. Honour Retry-After if present.
retryAfter := resp.Header.Get("Retry-After")
resp.Body.Close()
if attempt == maxAttempts {
return nil, &GeocodeError{Status: resp.StatusCode, Message: "exhausted retries"}
}
delay := backoff(attempt)
if retryAfter != "" {
if secs, err := strconv.ParseFloat(retryAfter, 64); err == nil {
delay = time.Duration(secs * float64(time.Second))
}
}
if err := sleep(ctx, delay); err != nil {
return nil, err
}
}
return nil, fmt.Errorf("unreachable: lastErr=%v", lastErr)
}
func backoff(attempt int) time.Duration {
// 1, 2, 4, 8, 16... seconds + up to 1s jitter.
base := time.Duration(1<<(attempt-1)) * time.Second
jitter := time.Duration(rand.Float64() * float64(time.Second))
return base + jitter
}
func sleep(ctx context.Context, d time.Duration) error {
t := time.NewTimer(d)
defer t.Stop()
select {
case <-ctx.Done():
return ctx.Err()
case <-t.C:
return nil
}
}Algunas notas sobre el diseño. time.NewTimer más un select sobre ctx.Done() es la forma canónica de implementar un sleep cancelable — time.Sleep ignora el context y dejará tu goroutine fijada durante un deploy o un SIGTERM. El jitter en backoff importa cuando muchos workers reintentan simultáneamente; sin él cada worker despierta en el mismo instante y re-DDoSea al servidor. Mira exponential backoff: cuándo reintentar, cuándo parar para la matemática de presupuestos de retry y dead-letter queues.
Tres headers de rate-limit vuelven en cada respuesta exitosa y vale la pena revisarlos en producción:
| Header | Significado | |---|---| | X-RateLimit-Limit | Techo por minuto de tu plan | | X-RateLimit-Remaining | Lo que te queda en esta ventana | | X-RateLimit-Reset | Segundos Unix hasta que la ventana se reinicie | | Retry-After | Enviado en 429s — segundos a esperar antes de reintentar |
Si Remaining cae bajo el 10% de Limit, desacelera voluntariamente — sleep, baja el semáforo, cambia a batch. Más barato que estrellarte contra 429s. La teoría más profunda de qué algoritmo de limiter produce estos headers está en token bucket vs leaky bucket vs sliding window.
El endpoint de batch
Los tiers de suscripción desde Starter ($49/mes) hacia arriba exponen un endpoint de batch que toma un array de direcciones en un solo POST. Topes del plan:
| Plan | Filas mensuales | Por minuto | Tamaño de batch | |---|---|---|---| | Free | — (1K/día API, 100/día batch) | 100 | 100 | | Starter ($49) | 50.000 | 1.000 | 1.000 | | Growth ($149) | 250.000 | 5.000 | 5.000 | | Pro ($499) | 1.000.000 | 10.000 | 10.000 |
// batch.go
package main
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"os"
"time"
)
type BatchRequest struct {
Addresses []string `json:"addresses"`
}
type BatchResponse struct {
Results []GeocodeResponse `json:"results"`
}
var batchClient = &http.Client{Timeout: 60 * time.Second}
func batchGeocode(ctx context.Context, addresses []string) (*BatchResponse, error) {
body, err := json.Marshal(BatchRequest{Addresses: addresses})
if err != nil {
return nil, err
}
req, err := http.NewRequestWithContext(ctx, "POST", baseURL+"/geocode", bytes.NewReader(body))
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "Bearer "+os.Getenv("CSV2GEO_KEY"))
req.Header.Set("Content-Type", "application/json")
resp, err := batchClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode >= 300 {
return nil, fmt.Errorf("batch http %d", resp.StatusCode)
}
var out BatchResponse
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
return nil, err
}
return &out, nil
}Las respuestas de batch preservan el orden de entrada: la posición N de la respuesta siempre corresponde a la posición N de la entrada. Sin necesidad de ida y vuelta de un campo id. Nota el batchClient dedicado con timeout de 60 segundos — un batch de 1.000 direcciones en Growth tarda 10–15 segundos de extremo a extremo, mucho más que el cliente de 10 segundos por petición usado para singles.
Streaming de CSV sin OOM
Si tu archivo de entrada tiene 100.000 filas, no quieres leerlo en un [][]string e iterar con range sobre él. Hazle streaming. El patrón es el worker pool: una goroutine lee filas del disco y las empuja a un channel de jobs, N worker goroutines consumen el channel y escriben resultados a un channel de salida, y una writer goroutine drena el channel de salida al disco.
// stream.go
package main
import (
"context"
"encoding/csv"
"fmt"
"io"
"os"
"sync"
)
type job struct {
ID string
Address string
}
type result struct {
ID string
Address string
Lat float64
Lng float64
Err string
}
const concurrency = 8
func streamGeocode(ctx context.Context, inPath, outPath string) error {
in, err := os.Open(inPath)
if err != nil {
return err
}
defer in.Close()
out, err := os.Create(outPath)
if err != nil {
return err
}
defer out.Close()
reader := csv.NewReader(in)
writer := csv.NewWriter(out)
defer writer.Flush()
header, err := reader.Read()
if err != nil {
return err
}
idIdx, addrIdx := indexOf(header, "id"), indexOf(header, "address")
if idIdx < 0 || addrIdx < 0 {
return fmt.Errorf("csv must have id,address columns")
}
if err := writer.Write([]string{"id", "address", "lat", "lng", "error"}); err != nil {
return err
}
jobs := make(chan job, concurrency*2)
results := make(chan result, concurrency*2)
var wg sync.WaitGroup
for i := 0; i < concurrency; i++ {
wg.Add(1)
go worker(ctx, &wg, jobs, results)
}
// Writer goroutine drains results to disk.
done := make(chan struct{})
go func() {
for r := range results {
_ = writer.Write([]string{
r.ID, r.Address,
fmt.Sprintf("%f", r.Lat), fmt.Sprintf("%f", r.Lng), r.Err,
})
}
close(done)
}()
// Producer: read CSV row by row.
for {
row, err := reader.Read()
if err == io.EOF {
break
}
if err != nil {
close(jobs)
wg.Wait()
close(results)
<-done
return err
}
jobs <- job{ID: row[idIdx], Address: row[addrIdx]}
}
close(jobs)
wg.Wait()
close(results)
<-done
return nil
}
func worker(ctx context.Context, wg *sync.WaitGroup, jobs <-chan job, results chan<- result) {
defer wg.Done()
for j := range jobs {
out, err := geocodeWithRetry(ctx, j.Address, "US", 5)
if err != nil {
results <- result{ID: j.ID, Address: j.Address, Err: err.Error()}
continue
}
if len(out.Results) == 0 {
results <- result{ID: j.ID, Address: j.Address, Err: "no_match"}
continue
}
top := out.Results[0]
if top.AccuracyScore < 0.7 {
results <- result{ID: j.ID, Address: j.Address, Err: "low_confidence"}
continue
}
results <- result{
ID: j.ID, Address: j.Address,
Lat: top.Location.Lat, Lng: top.Location.Lng,
}
}
}
func indexOf(row []string, name string) int {
for i, c := range row {
if c == name {
return i
}
}
return -1
}
func main() {
if len(os.Args) < 3 {
fmt.Fprintln(os.Stderr, "usage: stream input.csv output.csv")
os.Exit(1)
}
if err := streamGeocode(context.Background(), os.Args[1], os.Args[2]); err != nil {
fmt.Fprintln(os.Stderr, "error:", err)
os.Exit(1)
}
}Tres cosas que este script hace que el código de principiantes normalmente no hace. Primero, la entrada se lee fila por fila, nunca cargada entera. Segundo, los buffers de los channels son pequeños (concurrency*2) así el producer hace backpressure naturalmente sobre los workers — si todos los workers están ocupados, el producer bloquea en el envío del channel y el SO mantiene el archivo mayormente en disco. Tercero, los errores se graban como columna en vez de tirar abajo el job entero. Esa es la diferencia entre un script que termina un run de 100K filas y un script que tiene que reiniciarse con la pregunta dónde-paró.
Respuestas tipadas
Los structs al inicio de este post son la tipificación mínima viable. Si quieres cobertura completa con campos opcionales, omitempty es tu amigo — mantiene la salida JSON limpia cuando un campo está ausente y evita confusión de zero-value al decodificar una respuesta.
type Components struct {
HouseNumber string `json:"house_number,omitempty"`
Street string `json:"street,omitempty"`
City string `json:"city,omitempty"`
State string `json:"state,omitempty"`
PostalCode string `json:"postal_code,omitempty"`
Country string `json:"country,omitempty"`
}
type Meta struct {
ResponseTimeMs int `json:"response_time_ms"`
Source string `json:"source"`
}
type FullGeocodeResult struct {
FormattedAddress string `json:"formatted_address"`
Location Location `json:"location"`
Accuracy string `json:"accuracy"`
AccuracyScore float64 `json:"accuracy_score"`
Components Components `json:"components"`
}
type FullGeocodeResponse struct {
Query string `json:"query"`
Results []FullGeocodeResult `json:"results"`
Meta Meta `json:"meta"`
}Definir Accuracy como alias tipado (type Accuracy string más declaraciones const para cada valor) te da un domain check gratis para switch statements. El compilador no atrapará un typo en un literal de string, pero si haces switch sobre Accuracy, un valor no manejado al menos es visible en el diff.
Cosas que muerden a los devs de Go
Olvidar defer resp.Body.Close(). Cada http.Client.Do que devuelve una respuesta no-nil *debe* ir seguido de un Close sobre el body, incluso si nunca lees el body. El runtime de Go no devolverá la conexión TCP subyacente al pool de conexiones hasta que el body se cierre y se drene. Sáltatelo una vez y tu servicio filtrará file descriptors bajo carga hasta que el kernel rechace nuevos sockets.
Usar el http.Client por defecto. http.Get y compañía usan http.DefaultClient, que tiene Timeout: 0. Un servidor mal portado puede dejar esa goroutine bloqueada indefinidamente. Siempre declara un cliente a nivel de paquete con timeout explícito, o usa http.NewRequestWithContext con un deadline. Lo mismo aplica para la historia de p99 latency — si no tienes timeout, no tienes p99, tienes una cola que corre al infinito.
Deadlocks de channel sin buffer. Si un worker lee de results y la writer goroutine no ha empezado, el envío bloquea. Si el producer lee de jobs y ningún worker está vivo, el envío bloquea. El buffer concurrency*2 en el ejemplo de streaming es suficiente para forward progress; elige un buffer que deje al producer estar ~1 paso adelante de los workers sin almacenar la entrada entera.
Patrones ingenuos de WaitGroup + channel. Un bug común: cerrar el channel de results antes de que los workers terminen, así la writer goroutine ve un channel cerrado y sale con salida parcial. La corrección es la ordenación explícita al final de streamGeocode: close(jobs) primero (los workers salen al ver el channel jobs cerrado), luego wg.Wait() para confirmar que todos los workers terminaron, luego close(results), luego <-done para esperar al writer. Equivócate en ese orden y verás bugs de filas faltantes que solo reproducen en entradas largas.
Preguntas frecuentes
¿Necesito un SDK?
No. La biblioteca estándar — net/http, encoding/json, context, time — cubre todo en este post. El SDK que la mayoría de equipos terminan escribiendo es un wrapper delgado alrededor de geocodeWithRetry, un set de structs equivalente a Pydantic, y un helper de worker pool. Mantener ~150 líneas de tu propio código es más barato que rastrear el ritmo de release de un tercero y un breaking change vendoreado cada seis meses.
¿Debería tratar errores como valores?
Sí — eso es Go idiomático. El tipo *GeocodeError en la función de retry lleva el status HTTP como campo tipado para que los callers puedan switch sobre él sin parsear strings. Envuelve errores de red con fmt.Errorf("...: %w", err) para que errors.Is y errors.As sigan funcionando a través del call stack. Evita panic para cualquier cosa que cruce un boundary de API; reserva los panics para bugs genuinos del programador.
net/http versus fasthttp?
Quédate con net/http para clientes de geocoding. fasthttp es más rápido a nivel de microbenchmark pero tiene una API distinta, no soporta HTTP/2, y reusa objetos request/response entre goroutines de formas que muerden cuando los entregas a un worker pool. El bottleneck en un pipeline de geocoding es el round trip de red, no el parser HTTP — fasthttp te ahorrará nanosegundos mientras la API toma cientos de milisegundos.
¿Cómo afino concurrency?
Toma el rate limit por minuto de tu plan, divídelo entre 60, y apunta a aproximadamente ese número de peticiones concurrentes. 1.000/min ÷ 60 ≈ 17, así un pool de 16 es un default seguro en Starter. La fuente de verdad es el header X-RateLimit-Remaining — loguéalo en cada respuesta exitosa y observa si cae más rápido que 1 por petición. La concurrency óptima es una curva, y el codo suele estar cerca de (rate_limit / 60) * 1.5. Metodología completa en concurrency tuning para geocoding.
¿Cómo detecto un no-match versus un match exitoso de baja confianza?
results es un slice vacío en no-match. En un match de baja confianza results[0] existe pero accuracy es "postcode" o "place" en lugar de "houseNumber" o "street", y accuracy_score está bien por debajo de 1.0. Un threshold default razonable es 0.7; por debajo, trátalo como no-match. Pipelines más estrictos (scoring de riesgo en seguros, mapeo de pacientes en salud) usan 0.95 y exigen accuracy == "houseNumber". El cuadro completo está en scores de confianza en geocoding explicados.
¿Esto funciona para direcciones fuera de EE. UU.?
Sí. Pasa el ISO alpha-2 correcto en el parámetro country — DE para Alemania, GB para Reino Unido, BR para Brasil, JP para Japón. La cobertura abarca 39 países hoy, incluyendo el top 10 completo por conteo de direcciones: EE. UU. (121M direcciones), Brasil (90M), México (30M), Francia (26M), Italia (26M), y el resto. La página de la API lista los conteos por país.
¿Debería usar peticiones single o el endpoint de batch?
Batch cuando tengas ≥100 direcciones que hacer de una vez y tu plan lo permita (Starter+). Un POST es más barato para ambos lados que 100 GETs, y el ahorro de latencia es aproximadamente avg_per_request_ms * count / concurrency. Singles son más simples para workloads streaming donde las direcciones llegan con el tiempo, y singles son la única opción en el free tier. El análisis completo de tradeoff está en batch vs realtime geocoding.
Hacia dónde ir desde aquí
La referencia completa de la API está en csv2geo.com/api. Si prefieres trabajar en otro lenguaje, el tutorial de geocoding en Node.js sigue la misma estructura con fetch y p-limit, y el tutorial de geocoding en Python cubre httpx y asyncio. Para inmersiones más profundas en los patrones que toca este post: algoritmos de rate limiting comparados, concurrency tuning para geocoding, y por qué p99 latency importa más que el promedio.
Si encuentras un edge case, te topas con un formato de respuesta que este post no cubre, o quieres feedback sobre un pipeline Go que estás construyendo, el formulario de contacto del sitio llega a una persona que lo lee. Bug reports con curl (o go run) reproducciones se arreglan rápido.
I.A. / CSV2GEO Creator
Artículos relacionados
- Tutorial de Node.js Geocoding API: Concurrency, Reintentos y Streaming de CSV
- Tutorial de Python Geocoding API: Async, Reintentos y Pipeline CSV de 100K Filas
- Concurrency Tuning para Geocoding: Encontrando Tu Sweet Spot
- Rate Limiting de un Pipeline de Geocoding: Token Bucket vs Leaky Bucket vs Sliding Window
- p99 Latency en Geocoding: Por Qué Tu Promedio Miente
- Patrones de Monorepo para Clientes de Geocoding Multi-Lenguaje
Use our batch geocoding tool to convert thousands of addresses to coordinates in minutes. Start with 100 free addresses.
Try Batch Geocoding Free →