Replication, part 1: load a full extract into your database
Stream all postcodes, streets and addresses, store them in SQLite or Postgres, and save the sequence number to keep syncing from.
If you need every Danish address in your own database, for joins, batch jobs or offline use, you use the replication API. It works in two steps: first a full extract of each entity, then ongoing events with the changes. This guide covers the first step. Part 2 covers the events.
The endpoints are the same as in DAWA's replication: /replikering/senestesekvensnummer, /replikering/udtraek?entitet=… and /replikering/haendelser?entitet=…. We serve them ourselves from our own copy of the address register. Every call except senestesekvensnummer needs a key, and an extract counts as one call, however many rows it contains.
You always start with a full extract. Our event log only goes back to 2026, and the source changed from DAWA to Datafordeleren when DAWA closed on 1 October 2026, so you cannot rebuild a copy from events alone or continue from an old DAWA cursor.
1. Choose entities and order
Extracts exist for these entities: postnummer, kommune, region, sogn, retskreds, politikreds, opstillingskreds, afstemningsomraade, supplerendebynavn, navngivenvej, vejstykke, adgangsadresse, adresse and bygning. Most projects only need six:
| Entity | Primary key | What it is |
|---|---|---|
postnummer | nr | Postcodes |
kommune | kode | Municipalities |
navngivenvej | id | Named streets |
vejstykke | kommunekode + vejkode | The part of a street within one municipality |
adgangsadresse | id | The building entrance with house number and coordinate |
adresse | id | The full address with floor and door |
Load them in that order, so whatever is referenced is in place first. An adresse points to its adgangsadresse through adgangsadresseid, and an adgangsadresse points to postnr, kommunekode and navngivenvej_id. See Address or access address for the data model.
2. Save the sequence number before you start
Call /replikering/senestesekvensnummer before the first extract and store the number. It is your starting point for events afterwards: you fetch events from that number + 1. (/replikering/senesteSekvensnummer with a capital S also works.)
{"sekvensnummer": 207940624, "tidspunkt": "2026-11-02T12:23:10.000Z"}The order matters. The adresse extract can take a quarter of an hour, and data changes meanwhile. If you take the sequence number before the extract, you afterwards fetch every change since that moment. Some of them are already in the extract, but that does no harm because you store with upserts. If you take the sequence number after the extract, you can lose changes made while it ran.
3. Look at the format
By default the extract is one JSON array, sent as a stream, like DAWA's. Add &ndjson to the URL and you get NDJSON instead: one JSON row per line (application/x-ndjson). NDJSON is the easiest to read row by row, so the examples here use it. Fetch the first three postcodes:
import json import os import requests BASE = "https://api.danadresse.dk" H = {"X-Api-Key": os.environ["DANADRESSE_API_KEY"]} seneste = requests.get(f"{BASE}/replikering/senestesekvensnummer", headers=H, timeout=10).json() print("seneste sekvensnummer:", seneste["sekvensnummer"]) # ndjson: one row per line. Without it you get one large JSON array. with requests.get(f"{BASE}/replikering/udtraek", params={"entitet": "postnummer", "ndjson": ""}, headers=H, stream=True, timeout=(5, 30)) as r: r.raise_for_status() for i, linje in enumerate(r.iter_lines()): print(json.loads(linje)) if i == 2: break
The rows are the columns of our own tables, so the field names are not exactly DAWA's replication attribute names. /replikering/datamodel lists the fields and the primary key of each entity; read it instead of assuming names. An adresse row looks like this:
{"id": "000021c5-e9ee-411d-b2d8-ec9161780ccd", "status": 1, "adgangsadresseid": "0a3f5086-e9cd-32b8-e044-0003ba298018", "etage": "2", "doer": "tv", "oprettet": "2018-08-02T12:47:31", "aendret": "2018-08-02T12:47:31"}Note that the extract uses field names without æ, ø and å (doer, aendret), and that adgangsadresse has both lat/lon (WGS84) and etrs89_x/etrs89_y (UTM32). The extract contains every status: 1 current, 3 provisional, 2 retired and 4 withdrawn. Filter on status if you only want to show active addresses.
4. Stream the extract into SQLite
The script below fetches each entity as a stream and writes in batches of 5,000 rows. The whole row is stored as JSON, so you do not have to maintain a schema for every field, and the primary key is read from /replikering/datamodel:
"""foerste_udtraek.py: fuldt udtræk af adressedata ind i SQLite. python foerste_udtraek.py # alle entiteter i ENTITETER python foerste_udtraek.py postnummer kommune # kun disse """ import json import os import sqlite3 import sys import time import requests BASE_URL = os.environ.get("DANADRESSE_BASE_URL", "https://api.danadresse.dk") HEADERS = {"X-Api-Key": os.environ["DANADRESSE_API_KEY"]} # Rækkefølge: det, der bliver refereret til, før det, der refererer ENTITETER = ["postnummer", "kommune", "navngivenvej", "vejstykke", "adgangsadresse", "adresse"] BATCH = 5000 def opret_tabeller(db: sqlite3.Connection, entiteter: list[str]) -> None: for e in entiteter: # pk = primærnøglen som tekst; data = hele rækken som JSON db.execute(f"CREATE TABLE IF NOT EXISTS {e} (pk TEXT PRIMARY KEY, status INTEGER, data TEXT NOT NULL)") db.execute("CREATE TABLE IF NOT EXISTS replikering_cursor " "(entitet TEXT PRIMARY KEY, sekvensnummer INTEGER NOT NULL)") db.commit() def hent_json(path: str) -> dict: r = requests.get(BASE_URL + path, headers=HEADERS, timeout=(5, 30)) r.raise_for_status() return r.json() def hent_udtraek(db: sqlite3.Connection, entitet: str, noegle: list[str]) -> int: sql = (f"INSERT INTO {entitet} (pk, status, data) VALUES (?, ?, ?) " "ON CONFLICT(pk) DO UPDATE SET status = excluded.status, data = excluded.data") antal = 0 rows: list[tuple] = [] # stream=True: rækkerne skrives, mens de kommer, i stedet for at ligge i hukommelsen. # Read-timeout gælder mellem to bidder af svaret, ikke hele overførslen. # ndjson: én række pr. linje, så iter_lines() kan læse rækkerne én ad gangen. with requests.get(f"{BASE_URL}/replikering/udtraek", params={"entitet": entitet, "ndjson": ""}, headers=HEADERS, stream=True, timeout=(5, 120)) as r: r.raise_for_status() for linje in r.iter_lines(): if not linje: continue raekke = json.loads(linje) pk = "-".join(str(raekke[k]) for k in noegle) rows.append((pk, raekke.get("status"), json.dumps(raekke, ensure_ascii=False))) if len(rows) >= BATCH: db.executemany(sql, rows) db.commit() antal += len(rows) rows.clear() if rows: db.executemany(sql, rows) db.commit() antal += len(rows) return antal def main(entiteter: list[str]) -> None: db = sqlite3.connect("adresser.db") opret_tabeller(db, entiteter) model = hent_json("/replikering/datamodel") # primærnøgle pr. entitet # 1. Gem sekvensnummeret FØR udtrækket. Ændringer under udtrækket hentes # bagefter som hændelser og overskriver idempotent. start = hent_json("/replikering/senestesekvensnummer")["sekvensnummer"] print("start-sekvensnummer:", start) for entitet in entiteter: t0 = time.monotonic() antal = hent_udtraek(db, entitet, model[entitet]["key"]) # 2. Cursor pr. entitet: herfra skal hændelser hentes næste gang db.execute("INSERT INTO replikering_cursor (entitet, sekvensnummer) VALUES (?, ?) " "ON CONFLICT(entitet) DO UPDATE SET sekvensnummer = excluded.sekvensnummer", (entitet, start)) db.commit() print(f"{entitet}: {antal} rækker på {time.monotonic() - t0:.0f} s") if __name__ == "__main__": main(sys.argv[1:] or ENTITETER)
Typical output for the small entities:
start-sekvensnummer: 207940624 postnummer: 1108 rækker på 0 s kommune: 99 rækker på 1 s navngivenvej: 112484 rækker på 31 s vejstykke: 113877 rækker på 29 s
adgangsadresse and adresse have millions of rows. Expect 10 to 30 minutes for each, depending on your connection and database. If the connection drops halfway, run the script again for that entity. Upserts mean rows you already have are simply overwritten.
Why stream? requests.get(...).json() on the whole adresse extract would read several gigabytes into memory before storing the first row. With &ndjson, iter_lines() handles one line at a time. If you prefer the default JSON array, read it with a streaming JSON parser such as ijson (ijson.items(r.raw, "item")), never with .json().
5. Postgres instead of SQLite
In Postgres, jsonb and ON CONFLICT are the natural choice. Use || in the update so fields from later events are merged in instead of replacing the whole row:
CREATE TABLE adresse ( pk text PRIMARY KEY, status smallint, data jsonb NOT NULL ); CREATE INDEX adresse_adgangsadresseid ON adresse ((data->>'adgangsadresseid')); -- Én række pr. linje i udtrækket (psycopg: cur.executemany(sql, rows)) INSERT INTO adresse (pk, status, data) VALUES (%s, %s, %s::jsonb) ON CONFLICT (pk) DO UPDATE SET status = EXCLUDED.status, data = adresse.data || EXCLUDED.data; CREATE TABLE replikering_cursor (entitet text PRIMARY KEY, sekvensnummer bigint NOT NULL);
For very large volumes, COPY into a temporary table followed by one INSERT … SELECT … ON CONFLICT is faster than executemany.
6. Keep the copy up to date
When the extract is done, the starting sequence number is in replikering_cursor for each entity. From there you fetch changes with /replikering/haendelser?entitet=…. That is described in Replication, part 2: events. If you want to be told when there is something new instead of asking regularly, see Webhooks.
Entities that rarely change and have no ongoing events (such as kommune, region and sogn) can be fetched again with a new extract, for example once a month.
The data comes from Klimadatastyrelsen via Datafordeleren. If you store a copy, remember the attribution "Indeholder data fra Klimadatastyrelsen (CC BY 4.0)".
Related guides
Markdown source Spotted an error? Tell us API-reference (Swagger)