# 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.
>
> https://danadresse.dk/en/guides/replikering-foerste-udtraek · verified 2026-11-02
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](/en/guides/replikering-haendelser) 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](/en/guides/adresse-vs-adgangsadresse) 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.)

```json id=seneste-response run=no
{"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:

```python id=udtraek-peek run=live expect="København K"
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:

```json id=adresse-row run=no
{"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`:

```python id=udtraek-sqlite run=syntax
"""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:

```text id=udtraek-output run=no
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:

```sql id=postgres run=no
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](/en/guides/replikering-haendelser). If you want to be told when there is something new instead of asking regularly, see [Webhooks](/en/guides/webhooks-opsaetning).

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.

> [!NOTE]
> DAWA's old path `/replikering/{entitet}` (for example `/replikering/adgangsadresser`) gives the same extract, also with `&ndjson`. `bygning` is only available as `/replikering/udtraek?entitet=bygning`.

The data comes from Klimadatastyrelsen via Datafordeleren. If you store a copy, remember the attribution "Indeholder data fra Klimadatastyrelsen (CC BY 4.0)".
