Caching address lookups

See which responses you can cache and for how long, what the API's Cache-Control and ETag mean, and build a cache in Python with and without Redis.

4 min Level: Intermediate ✓ Code tested

Addresses rarely change. A given address can stay the same for years, and postcodes and municipalities almost never change. That means a large share of your calls can be answered from a cache instead of from the API. It makes your site faster and saves quota.

This guide shows what we put in Cache-Control, how ETag works with us, and two small cache implementations.

What the API sends

Every successful response has a Cache-Control header. The value depends on the endpoint:

EndpointCache-Control
/autocomplete and /<resource>/autocompleteprivate, max-age=300
/adresser and /adgangsadresser (lists)private, max-age=300
/adresser/{id} and /adgangsadresser/{id}private, max-age=3600
/adgangsadresser/reverseprivate, max-age=60
/postnumre, /kommuner, /regioner, /sogne and the other DAGI areas, /vejstykker, /vejnavne, /navngivneveje, /jordstykker, /matrikelnumre, /ejerlav, /steder, /stednavneprivate, max-age=86400, stale-while-revalidate=604800
/datavask/… (GET)private, max-age=86400
/api/v1/enrich/…private, max-age=86400
Every error responseno-store

private means your own client may store the response, but a shared cache in between (a CDN or a shared proxy) may not. The response belongs to your key. A browser that fetches /postnumre therefore reuses the response for a day without asking again.

Never cache errors. If you store a 429 or 503 response, the error keeps coming back after the cause is gone.

ETag and 304

JSON responses up to 256 KB carry a weak ETag, e.g. ETag: W/"3f2a9c1b0d4e5f61". If you send it back in If-None-Match and the response has not changed, you get 304 Not Modified with no body:

Python#etag-304
import os

import requests

URL = "https://api.danadresse.dk/postnumre/8000"
headers = {"X-Api-Key": os.environ["DANADRESSE_API_KEY"]}

first = requests.get(URL, headers=headers, timeout=10)
etag = first.headers.get("ETag")
print("first call: ", first.status_code, etag)

second = requests.get(URL, headers={**headers, "If-None-Match": etag}, timeout=10)
print("second call:", second.status_code, len(second.content), "bytes")

A 304 response still counts as a call. The key is checked and usage is counted before the response is produced. ETag saves bandwidth and parsing, not quota. To save calls, the response must come from your own cache without asking the API.

What to cache, and for how long

DataSuggested lifetimeWhy
Autocomplete suggestionsMinutes, in memoryUsers delete and retype. The same query often comes several times in one session
Lookups by id (/adresser/{id})Hours to a dayAn address rarely changes. Store the id in your database anyway
Postcodes, municipalities, regions, DAGIA day or moreChange at most a few times a year
Datavask of the same textA dayThe same input gives the same answer until the register changes
Reverse geocodingShort, or per rounded coordinateGPS points vary, so the same key is rarely hit exactly

The cache key must contain the path and every parameter, e.g. srid, struktur and per_side. Otherwise a caller asking for srid=25832 gets a WGS84 answer. Never put the API key in the cache key or in logs.

If you need the whole dataset, fetch it with replication instead of caching your way to it with single calls. Our terms also say the full dataset must be fetched through replication. See First extract.

A TTL cache in memory

For a single process, a dictionary with an expiry time is enough. The example looks up the same postcode twice but calls the API only once:

Python#ttl-cache
import os
import time

import requests

API = "https://api.danadresse.dk"
session = requests.Session()
session.headers["X-Api-Key"] = os.environ["DANADRESSE_API_KEY"]

_cache: dict[tuple, tuple[float, object]] = {}
api_calls = 0


def get(path: str, params: dict | None = None, ttl: float = 3600):
    global api_calls
    key = (path, tuple(sorted((params or {}).items())))
    hit = _cache.get(key)
    if hit and hit[0] > time.monotonic():
        return hit[1]
    r = session.get(API + path, params=params, timeout=10)
    r.raise_for_status()  # errors are not cached
    api_calls += 1
    data = r.json()
    _cache[key] = (time.monotonic() + ttl, data)
    return data


for _ in range(2):
    print(get("/postnumre/8000", ttl=86400)["navn"])
print(f"API calls: {api_calls}")

This cache grows without limit. For autocomplete with many different queries, use a cache with a maximum size, e.g. cachetools.TTLCache in Python.

A shared cache in Redis

If you run several processes or servers, put the cache in Redis so every instance shares the same responses. The lifetime is chosen from the path:

Python#redis-cache
import hashlib
import json
import os

import redis
import requests

API = "https://api.danadresse.dk"
r = redis.Redis.from_url(os.environ.get("REDIS_URL", "redis://localhost:6379/0"))
session = requests.Session()
session.headers["X-Api-Key"] = os.environ["DANADRESSE_API_KEY"]

# Lifetime in seconds per path prefix. The first match wins.
TTL = [
    ("/autocomplete", 300),
    ("/adgangsadresser/reverse", 60),
    ("/adresser/", 86400),
    ("/adgangsadresser/", 86400),
    ("/postnumre", 7 * 86400),
    ("/kommuner", 7 * 86400),
    ("/datavask/", 86400),
]


def ttl_for(path: str) -> int:
    for prefix, seconds in TTL:
        if path.startswith(prefix):
            return seconds
    return 300


def get(path: str, params: dict | None = None):
    raw = json.dumps([path, sorted((params or {}).items())], ensure_ascii=False)
    key = "danadresse:" + hashlib.sha256(raw.encode()).hexdigest()
    stored = r.get(key)
    if stored is not None:
        return json.loads(stored)
    resp = session.get(API + path, params=params, timeout=10)
    resp.raise_for_status()  # only 2xx is cached
    data = resp.json()
    r.setex(key, ttl_for(path), json.dumps(data, ensure_ascii=False))
    return data

If Redis goes down, your code should call the API directly instead of failing. Wrap r.get and r.setex in a try if the cache must never break the request.

In the browser

For an address field it is enough to remember the latest queries in a Map. A user who types "Vest", deletes and types "Vest" again gets the second answer without a new call:

JavaScript#browser-cache
const cache = new Map();
const MAX = 100;

export async function suggestions(q, apiKey) {
  if (cache.has(q)) return cache.get(q);
  const url = new URL("https://api.danadresse.dk/autocomplete");
  url.search = new URLSearchParams({ q, api_key: apiKey });
  const res = await fetch(url);
  if (!res.ok) return []; // errors are not stored
  const data = await res.json();
  cache.set(q, data);
  if (cache.size > MAX) cache.delete(cache.keys().next().value); // drop the oldest
  return data;
}

The key here must be a domain-locked browser key. See Browser keys and allowed domains.

Next steps

Related guides

Markdown source Spotted an error? Tell us API-reference (Swagger)

Try it with a free key

2,000 calls/month on the free plan. No credit card.

Create account →