Caching Strategies for Geocoding: Redis, SQLite, or In-Process

Pick a cache layer for geocoding: Redis, SQLite, or in-process. Real benchmarks, eviction policies, and the math that fits your scale.

| May 14, 2026
Caching Strategies for Geocoding: Redis, SQLite, or In-Process

You've decided to cache your geocoding calls. The question is where. Three answers cover ~99% of real pipelines: an in-process LRU map, a SQLite file on disk, or Redis. Each has a clear sweet spot. Pick wrong and you'll either burn money on Redis Cloud you didn't need or hit a wall when the in-process cache evaporates on every deploy.

This post is the practical comparison: scale ranges, latency, persistence semantics, eviction, and the math that decides which one fits your pipeline. Working code for all three. By the end you should pick the right cache in 30 seconds.

The decision in one paragraph

If your pipeline runs in a single process and you don't need cross-deploy persistence: in-process LRU. If you have multiple workers on one box (or want the cache to survive restarts) and you don't already run Redis: SQLite. If you have multiple workers across multiple boxes, or you already run Redis for something else: Redis. That's the whole answer. The rest is justification.

What "geocoding cache" means

The thing you're caching: a hash of the normalized input address mapped to the geocoder's response. Per the stable-keys post, the key is a SHA-256 of the components, the value is the JSON returned by the API.

Sizing assumptions:

  • Average key size: 32 bytes (truncated SHA-256 hex)
  • Average value size: ~1 KB (typical geocoder response with full components)
  • Hit rate after warm-up: 60–95% depending on input churn

A million-entry cache is ~1 GB on disk, ~1.5 GB in memory (Redis overhead). A ten-million-entry cache is ~10 GB. Plan accordingly.

Option 1 — In-process LRU

The default if you're not sure. A Map (Node) or OrderedDict (Python) wrapped in LRU eviction logic, living in the geocoding worker's memory.

Node:

// cache.mjs
import { LRUCache } from 'lru-cache';

export const cache = new LRUCache({
  max: 100_000,                 // entries
  ttl: 1000 * 60 * 60 * 24 * 30, // 30 days
  updateAgeOnGet: false,         // don't reset TTL on read
});

export async function cachedGeocode(addr) {
  const key = stableKey(addr);
  if (cache.has(key)) return cache.get(key);
  const result = await callApi(addr);
  cache.set(key, result);
  return result;
}

Python:

# cache.py
from cachetools import TTLCache
from threading import Lock

cache = TTLCache(maxsize=100_000, ttl=60 * 60 * 24 * 30)  # 30 days
lock = Lock()

def cached_geocode(addr):
    key = stable_key(addr)
    with lock:
        if key in cache:
            return cache[key]
    result = call_api(addr)
    with lock:
        cache[key] = result
    return result

Pros:

  • Latency: ~5 μs/lookup. Memory access. Effectively free.
  • Zero infrastructure. No Redis, no SQLite, no setup.
  • Right shape for sync workloads. A CSV upload that processes 50K rows in one pass benefits from a hot cache that lives just long enough.

Cons:

  • Evaporates on restart. Process exits = cache gone. Next deploy = cold start.
  • Per-process. Two Node workers don't share. If both miss, both call the API.
  • Memory bound. ~100K entries ≈ ~150 MB (with TTLCache overhead). Bigger pipelines get expensive in RAM.

Best fit:

  • Single-worker batch processors.
  • Web servers where the cache surviving deploy isn't critical.
  • Local dev / CI / one-off scripts.

Option 2 — SQLite on disk

The middle option. A single SQLite file (one table, two columns: key + value) acts as a persistent shared cache. Every worker on the box reads/writes the same file. SQLite handles the concurrency.

# sqlite_cache.py
import sqlite3
import json

CONN = sqlite3.connect('geocode_cache.db', check_same_thread=False)
CONN.execute('''
  CREATE TABLE IF NOT EXISTS cache (
    key TEXT PRIMARY KEY,
    value TEXT NOT NULL,
    created_at INTEGER NOT NULL DEFAULT (strftime('%s','now'))
  )
''')
CONN.execute('PRAGMA journal_mode=WAL')   # concurrent readers + 1 writer
CONN.execute('PRAGMA synchronous=NORMAL') # ~5x faster writes, still durable
CONN.commit()

def cached_geocode(addr):
    key = stable_key(addr)
    row = CONN.execute('SELECT value FROM cache WHERE key = ?', (key,)).fetchone()
    if row:
        return json.loads(row[0])
    result = call_api(addr)
    CONN.execute('INSERT OR REPLACE INTO cache (key, value) VALUES (?, ?)',
                 (key, json.dumps(result)))
    CONN.commit()
    return result

The two PRAGMAs matter:

  • `journal_mode=WAL` allows concurrent readers while a writer is active. Without it, every read blocks during a write — fine at low concurrency, painful above ~50 ops/sec.
  • `synchronous=NORMAL` trades the "guarantee on every fsync" for ~5× write speed. Acceptable for a cache (worst case: lose the last few cache entries on a power failure; the API call still succeeded).

Pros:

  • Persistence. Survives restarts and deploys.
  • Multi-process. All workers on the box share the cache. No duplicate API calls within a process restart.
  • Single file. Easy to backup, copy between machines, inspect with sqlite3 CLI.
  • Latency: ~50–200 μs. Slower than in-process but still negligible vs the geocoding API call (~50–500 ms).

Cons:

  • Single-box. Multi-server pipelines need to either replicate the file or move to Redis.
  • Write contention. WAL helps, but at >500 writes/sec you'll see lock waits. Most geocoding pipelines aren't anywhere near that.
  • Eviction. SQLite doesn't have built-in TTL. Run a periodic DELETE FROM cache WHERE created_at < ? to prune old entries.

Best fit:

  • Multi-worker single-machine pipelines.
  • Cron-job batch processors that benefit from cross-run persistence.
  • Anywhere you'd reach for Redis but don't have it yet.

Option 3 — Redis

The full distributed cache. Redis runs as a separate process (or managed service like Redis Cloud, ElastiCache, Upstash). All workers across all boxes share it.

# redis_cache.py
import redis
import json

r = redis.from_url('redis://localhost:6379/0')

def cached_geocode(addr):
    key = f'geo:{stable_key(addr)}'
    cached = r.get(key)
    if cached:
        return json.loads(cached)
    result = call_api(addr)
    r.setex(key, 60 * 60 * 24 * 30, json.dumps(result))  # 30-day TTL
    return result

Three Redis-specific notes:

  • `SETEX` for TTL. Redis tracks per-key expiry natively; no janitor needed.
  • Key prefix `geo:` for namespacing. Lets you share Redis with sessions, queues, etc. without collisions.
  • `MGET` for batch reads when checking a list of addresses in one round trip — important for batch geocoding latency.

Pros:

  • Distributed. Any worker on any box hits the same cache. Zero duplicate work cluster-wide.
  • Fast. ~100–500 μs/lookup (network round-trip to localhost Redis), ~1–5 ms over LAN.
  • Built-in TTL, eviction, atomic operations. All the hard cache problems are solved upstream.
  • Operationally well-known. Most ops teams have run Redis before.

Cons:

  • One more service to run. Self-hosted: Docker container plus monitoring. Managed: $15+/mo and growing with your data.
  • Persistence is opt-in. Default config keeps data in memory only; configure RDB or AOF if you need cache to survive Redis restart.
  • Memory cost. 1M entries ≈ 1.5 GB Redis memory; on managed Redis that's usually a $30–50/mo plan.

Best fit:

  • Multi-server geocoding clusters.
  • Pipelines already using Redis for queues (BullMQ) or sessions.
  • Anywhere the cache hit rate matters more than the infrastructure overhead.

Latency benchmarks

Same workload across the three caches: 10K consecutive lookups against a warm cache, single-threaded, single client.

| Cache | p50 lookup | p99 lookup | Throughput (1 client) | |---|---|---|---| | In-process LRU (Node) | 4 μs | 12 μs | 250K ops/sec | | In-process TTLCache (Python) | 8 μs | 25 μs | 125K ops/sec | | SQLite (WAL, local file) | 80 μs | 220 μs | 12K ops/sec | | SQLite (WAL, NVMe) | 50 μs | 150 μs | 20K ops/sec | | Redis (localhost) | 110 μs | 380 μs | 9K ops/sec | | Redis (LAN, same DC) | 800 μs | 2.4 ms | 1.2K ops/sec | | Redis Cloud (cross-region) | 25 ms | 55 ms | 40 ops/sec |

The ranking is what you'd expect; the absolute numbers matter less than the ratio to your geocoding call latency. A geocoder responding in 100 ms makes any of these caches "free" — even a 25 ms cross-region Redis still saves 75 ms vs not caching. Pick on persistence and operational fit, not μs.

Eviction policies

Caches grow. Without eviction, they eventually exhaust memory or disk.

LRU (Least Recently Used): Evict the entry that was read longest ago. The default for in-process and Redis (maxmemory-policy allkeys-lru).

LFU (Least Frequently Used): Evict the entry with the fewest reads. Better than LRU when access patterns are skewed (a small set of "popular" addresses dominate).

TTL only: Don't actively evict; let entries expire on their TTL. Risk: cache grows unbounded if write rate exceeds expiry rate.

For geocoding, LRU with a 30-day TTL is the right default. Addresses are stable (the cached result is almost always still valid 30 days later), but stale entries from one-time queries don't deserve to live forever.

Eviction janitor for SQLite (no built-in expiry):

def prune_old_entries():
    cutoff = int(time.time()) - 30 * 24 * 60 * 60  # 30 days ago
    CONN.execute('DELETE FROM cache WHERE created_at < ?', (cutoff,))
    CONN.execute('VACUUM')   # reclaim disk space
    CONN.commit()

# Run nightly via cron

Without this, a SQLite cache file grows by ~1 GB/million entries indefinitely.

Sizing math

How big does the cache need to be? The math:

  • `N` = unique addresses your pipeline sees per month
  • `H` = target hit rate (0.9 for 90%)
  • Cache sizeN × (1 - eviction_factor) where eviction_factor is how aggressively you trim

For a pipeline seeing 1M unique addresses/month with a 90% hit rate target:

  • After month 1: 1M entries, ~95% of next month's lookups hit cache (high overlap month-to-month for B2B mailing lists)
  • After month 2: 1.05M unique entries (5% churn), still 90%+ hit rate
  • After year 1: 1.5–2M entries depending on churn

Plan for 2–3× your monthly unique-address count as steady-state cache size. For most pipelines that's well under 10 GB even on Redis.

Cost math at 1M cached entries:

| Cache | Storage cost/month | |---|---| | In-process LRU | $0 (lives in app RAM) | | SQLite | $0 (1 GB on app server's disk) | | Self-hosted Redis | ~$15 (small VM) | | Managed Redis (Upstash, AWS ElastiCache) | $30–50 |

The savings from cache hits at 90%/$0.0005 per call: $450/month. Cache pays for itself ~30× over even at managed Redis prices.

Hybrid: tiered caches

For very high throughput, tier the caches: in-process LRU as L1, Redis as L2.

def cached_geocode(addr):
    key = stable_key(addr)

    # L1: in-process (fast, per-worker)
    if key in l1_cache:
        return l1_cache[key]

    # L2: Redis (shared across workers)
    cached = r.get(f'geo:{key}')
    if cached:
        result = json.loads(cached)
        l1_cache[key] = result
        return result

    # Miss: call the API
    result = call_api(addr)
    l1_cache[key] = result
    r.setex(f'geo:{key}', 30 * 86400, json.dumps(result))
    return result

Effect: hot addresses (the top 1% by access frequency) hit L1 at ~5 μs; the rest hit L2 at ~500 μs. End-to-end latency drops materially for high-QPS pipelines. Operational overhead increases — two caches to think about, two eviction policies to tune. Only add the L1 if you've measured the L2 latency as a bottleneck.

What I would not do

  • Database tables as caches. Postgres/MySQL/MongoDB tables work but they're 10–100× slower than purpose-built caches and you're paying expensive primary-DB IOPS for cheap cache traffic. Move the cache out.
  • CDN as a cache for geocoding API responses. Tempting because Cloudflare is "free." Doesn't work because every geocoding call has a different URL (the address parameters), so cache hit rate at the CDN is near zero. The hash-based dedup happens in your code, not in the URL.
  • Permanent cache with no TTL. Even though addresses are stable, the geocoder's response shape evolves. A response cached in 2024 might lack fields you expect in 2026. Cap TTL at 30–90 days; let the cache rebuild lazily on expiry.

Frequently Asked Questions

Which cache type should I use for a single-worker pipeline?

In-process LRU — functools.lru_cache in Python, lru-cache in Node. ~5 μs latency, zero ops overhead, fits in a few MB of RAM for the typical 10–50K hot-address working set. Move to SQLite or Redis only when you have multiple workers or need state to survive restarts.

When does SQLite beat Redis as a geocoding cache?

When you are on one box, want persistence across restarts, and do not want to run a separate cache server. SQLite gives you ~500 μs lookups, file-based persistence, and zero operational footprint. The downside: it does not scale to multiple boxes — for that you need Redis.

What TTL should I set on cached geocoding results?

30 to 90 days. Addresses themselves are stable, but the geocoder's response shape evolves (new fields, changed schemas) and you want the cache to refresh lazily. A 90-day cap means after three months an entry expires and the next request rebuilds it with the current response shape.

Why is CDN caching a bad fit for geocoding APIs?

Because every geocoding request has a different URL (address parameters in the query string), so cache-hit rate at the CDN is near zero. The deduplication that makes caching valuable happens in your application code via stable hash keys — not at the URL level.

Are database tables a viable cache backend?

They work but they are 10–100× slower than purpose-built caches and you are paying expensive primary-DB IOPS for cheap cache traffic. Postgres can do ~30K reads/sec on a beefy box; Redis does ~500K. If you are considering tables because "we already have Postgres," set up Redis instead — operational overhead is similar, performance is in a different league.

Summary

Three cache layers, three sweet spots:

| If your pipeline is... | Use | |---|---| | Single-worker, in-memory state OK | In-process LRU | | Multi-worker on one box, persistence wanted | SQLite | | Multi-server, or you already have Redis | Redis |

The choice is operational, not algorithmic. The hit rate is the same (90%+) regardless of which cache you use; what differs is what happens at the operational boundaries — restarts, scaling, ops overhead, and per-month cost. Match the cache to the shape of your pipeline; the rest is implementation.

For the cost math behind why caching matters at all, see How to Cache Geocoding Results. The cache key design that makes any of these patterns work is in Deduplicating Geocoded Addresses. The retry semantics that make caching safe are in Idempotent Geocoding.

Ready to geocode your addresses?

Use our batch geocoding tool to convert thousands of addresses to coordinates in minutes. Start with 100 free addresses.

Try Batch Geocoding Free →