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.
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:
| Endpoint | Cache-Control |
|---|---|
/autocomplete and /<resource>/autocomplete | private, max-age=300 |
/adresser and /adgangsadresser (lists) | private, max-age=300 |
/adresser/{id} and /adgangsadresser/{id} | private, max-age=3600 |
/adgangsadresser/reverse | private, max-age=60 |
/postnumre, /kommuner, /regioner, /sogne and the other DAGI areas, /vejstykker, /vejnavne, /navngivneveje, /jordstykker, /matrikelnumre, /ejerlav, /steder, /stednavne | private, max-age=86400, stale-while-revalidate=604800 |
/datavask/… (GET) | private, max-age=86400 |
/api/v1/enrich/… | private, max-age=86400 |
| Every error response | no-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:
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
| Data | Suggested lifetime | Why |
|---|---|---|
| Autocomplete suggestions | Minutes, in memory | Users delete and retype. The same query often comes several times in one session |
Lookups by id (/adresser/{id}) | Hours to a day | An address rarely changes. Store the id in your database anyway |
| Postcodes, municipalities, regions, DAGI | A day or more | Change at most a few times a year |
| Datavask of the same text | A day | The same input gives the same answer until the register changes |
| Reverse geocoding | Short, or per rounded coordinate | GPS 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:
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:
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:
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
- What to do when a call fails: Retries and backoff.
- See the effect on usage: Monitor usage.
Related guides
Markdown source Spotted an error? Tell us API-reference (Swagger)