# 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.
>
> https://danadresse.dk/en/guides/caching · verified 2026-11-02
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:

```python id=etag-304 run=live expect="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

| 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](/en/terms) also say the full dataset must be fetched through replication. See [First extract](/en/guides/replikering-foerste-udtraek).

## 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 id=ttl-cache run=live expect="API calls: 1"
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 id=redis-cache run=syntax
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:

```js id=browser-cache run=syntax
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](/en/guides/browser-noegler-domaener).

## Next steps

- What to do when a call fails: [Retries and backoff](/en/guides/retries-og-backoff).
- See the effect on usage: [Monitor usage](/en/guides/overvaag-forbrug).
