# Rens en CSV-fil fra CRM'et

> Vask tusindvis af kundeadresser fra en CSV-eksport, enten med et bulk-job på serveren eller med din egen løkke over datavask.
>
> https://danadresse.dk/guides/rens-crm-csv · verified 2026-11-02
Adresser i et CRM forfalder: kunder flytter, veje bliver omdøbt, og halvdelen blev tastet ind i hast. Før en kampagne eller en migrering vil du vide, hvilke rækker der er gode, hvilke der kan rettes automatisk, og hvilke et menneske skal kigge på.

Der er to veje. `POST /api/v1/bulk/cleanse` tager hele filen og kører datavask på hver række på serveren. En løkke over `GET /datavask/adresser` i din egen kode giver dig fuld kontrol over hvert kald. Begge giver kategorierne A, B og C. Afsnittet [Hvilken skal du vælge?](#hvilken-skal-du-vaelge) sammenligner dem.

## Bulk-jobbet

### Krav til filen

- Filnavnet skal ende på `.csv`, ellers svarer API'et `400` med koden `csv_required`.
- Højst 50 MB. En større fil giver `413` med koden `file_too_large`.
- Semikolon, komma eller tabulator som skilletegn. Jobbet finder selv skilletegnet og bruger det samme i resultatfilen. Et BOM-tegn fra Excel fjernes, og gamle Excel-filer i Windows-1252 læses også.
- Første linje er kolonnenavne, uden forskel på store og små bogstaver. Standardnavnene er `vejnavn`, `husnr`, `etage`, `dør` (eller `doer`), `postnr` og `postnrnavn`. Har du hele adressen i én kolonne, så kald den `betegnelse`. Hedder dine kolonner noget andet, så send navnene i formfelterne `vejnavn_col`, `husnr_col`, `etage_col`, `doer_col`, `postnr_col`, `postnrnavn_col` og `betegnelse_col`.

### Send filen

```bash id=bulk-upload run=syntax
curl -s "https://api.danadresse.dk/api/v1/bulk/cleanse" \
  -H "X-Api-Key: $DANADRESSE_API_KEY" \
  -F "file=@kunder.csv" \
  -F "vejnavn_col=Gade" \
  -F "husnr_col=Nr" \
  -F "postnr_col=Postnummer"
```

Svaret kommer med det samme, før rækkerne er behandlet:

```json id=bulk-svar run=no
{
  "job_id": "3f9c2a7e5b0d4c1e9a8f6b2d7c4e1a05",
  "status": "queued",
  "status_url": "/api/v1/bulk/jobs/3f9c2a7e5b0d4c1e9a8f6b2d7c4e1a05",
  "download_url": "/api/v1/bulk/jobs/3f9c2a7e5b0d4c1e9a8f6b2d7c4e1a05/download"
}
```

### Følg jobbet og hent resultatet

`GET /api/v1/bulk/jobs/{job_id}` returnerer `status` (`queued`, `processing`, `completed` eller `failed`), `processed` og `error`. `processed` opdateres for hver 100 rækker. `total` er antallet af rækker i filen og er kendt, så snart jobbet starter, så du kan vise en rigtig fremdrift. `GET /api/v1/bulk/jobs` viser dine seneste jobs.

Hent filen fra `download_url`, når status er `completed`. Spørger du for tidligt, får du `425` med koden `not_ready`. Jobbet huskes i én time efter, det er færdigt. Derefter svarer status og download `404` med `job_not_found`, så hent filen med det samme.

```python id=bulk-python run=syntax
import os
import time
import requests

API = "https://api.danadresse.dk"
H = {"X-Api-Key": os.environ["DANADRESSE_API_KEY"]}

with open("kunder.csv", "rb") as f:
    r = requests.post(
        f"{API}/api/v1/bulk/cleanse",
        headers=H,
        files={"file": ("kunder.csv", f, "text/csv")},
        data={"vejnavn_col": "vejnavn", "husnr_col": "husnr", "postnr_col": "postnr"},
        timeout=120,
    )
r.raise_for_status()
job = r.json()

while True:
    s = requests.get(API + job["status_url"], headers=H, timeout=10).json()
    print(s["status"], s["processed"])
    if s["status"] == "completed":
        break
    if s["status"] == "failed":
        raise SystemExit(s["error"])
    time.sleep(5)

out = requests.get(API + job["download_url"], headers=H, timeout=120)
out.raise_for_status()
with open("kunder_renset.csv", "wb") as f:
    f.write(out.content)
```

### Hvad står der i resultatet?

Filen har dine egne kolonner plus syv nye:

| Kolonne | Indhold |
|---|---|
| `_match_kategori` | `A`, `B` eller `C` fra datavask, eller `ERR` ved fejl i rækken |
| `_match_score` | Afledt af afstanden: `100 - 10 × afstand`, og `0` uden match |
| `_match_id` | Adresse-id for det bedste match |
| `_match_betegnelse` | Det bedste match som én linje, f.eks. `Strandvejen 100, 2900 Hellerup` |
| `_match_adgangsadresseid` | Adgangsadresse-id for det bedste match |
| `_match_afstand` | Datavaskens `afstand`. 0 er præcist |
| `_match_forskelle` | Datavaskens `forskelle` som JSON, f.eks. `{"vejnavn":2,"husnr":0,"postnr":0,"postnrnavn":0}` |

Bulk-jobbet kører den samme datavask som `GET /datavask/adresser` på hver række, så kategorierne betyder det samme: A er præcist, B er ét sikkert match med små stavefejl, og C er usikkert. Etage og dør tæller med. Ved C står det bedste bud i `_match_*`-kolonnerne, men lad en person afgøre rækken.

## Din egen løkke over datavask

Vil du styre hvert kald selv, f.eks. for at vise alle C-kandidaterne for en bruger, så kald `GET /datavask/adresser` pr. række. Datavask tager én adresse pr. kald.

Hold øje med minutgrænsen. Hvert svar har `X-RateLimit-Minute-Remaining`. Når den rammer 0, så vent til `X-RateLimit-Minute-Reset`. Får du alligevel `429`, så vent det antal sekunder, `Retry-After` siger. Se [Grænser, kvoter og 429](/guides/graenser-og-429).

Eksemplet læser tre rækker fra en tekststreng, så du kan køre det direkte. Udskift `io.StringIO(DATA)` med `open("kunder.csv", encoding="utf-8-sig")` for en rigtig fil:

```python id=loop-python run=live expect="_kategori"
import csv
import io
import os
import sys
import time
import requests

DATA = """kundenr,vejnavn,husnr,etage,dor,postnr
1001,Strandvejen,100,1,,2900
1002,Strandvej,100,,,2900
1003,Rådhuspladsen,1,,,1550
"""

s = requests.Session()
s.headers["X-Api-Key"] = os.environ["DANADRESSE_API_KEY"]

def vask(row):
    params = {k: row[k] for k in ("vejnavn", "husnr", "etage", "postnr") if row[k]}
    if row["dor"]:
        params["dør"] = row["dor"]
    while True:
        r = s.get("https://api.danadresse.dk/datavask/adresser", params=params, timeout=10)
        if r.status_code == 429:
            time.sleep(int(r.headers.get("Retry-After", "5")))
            continue
        r.raise_for_status()
        if r.headers.get("X-RateLimit-Minute-Remaining") == "0":
            reset = int(r.headers.get("X-RateLimit-Minute-Reset", "0"))
            time.sleep(max(1, reset - int(time.time())))
        return r.json()

reader = csv.DictReader(io.StringIO(DATA))
cols = reader.fieldnames + ["_kategori", "_adresse_id", "_adgangsadresse_id", "_vejnavn"]
out = csv.DictWriter(sys.stdout, fieldnames=cols)
out.writeheader()
for row in reader:
    svar = vask(row)
    top = svar["resultater"][0]["adresse"] if svar["resultater"] else {}
    row.update({
        "_kategori": svar["kategori"],
        "_adresse_id": top.get("id", ""),
        "_adgangsadresse_id": top.get("adgangsadresseid", ""),
        "_vejnavn": top.get("vejnavn", ""),
    })
    out.writerow(row)
```

Brug reglerne fra [Valider adressen i checkout](/guides/datavask-i-checkout): A og B kan du gemme med den officielle stavemåde, C skal en person se på. Ved C har `resultater` flere kandidater, og den første er kun det bedste bud.

## Hvilken skal du vælge?

| | Bulk-job | Egen løkke over datavask |
|---|---|---|
| Opsætning | Ét upload, ét download | Kode, retry og throttling |
| Kategorier | A, B og C | A, B og C |
| Etage og dør | Bruges | Bruges |
| Id'er | Adresse-id og adgangsadresse-id | Adresse-id og adgangsadresse-id |
| C-kandidater | Kun det bedste bud | Alle kandidater |
| Minutgrænse | Rammer dig ikke pr. række | Du skal selv vente |
| Store filer | Op til 50 MB pr. fil | Ingen grænse, men tager tid |

En god arbejdsgang er at køre bulk-jobbet på hele filen og derefter sende rækkerne med `C` gennem datavask-løkken, så en person kan vælge mellem kandidaterne.

## Gode vaner

- Overskriv aldrig kildedata direkte. Skriv resultatet til nye kolonner, og lad en person godkende C-rækkerne.
- Gem adresse-id'et og adgangsadresse-id'et i CRM'et. Næste gang kan du validere med id'et i stedet for teksten. Se [Gem adresse-id'et](/guides/gem-adresse-id).
- Brug en live-nøgle til store kørsler. Testnøgler har gratisplanens grænser pr. IP-adresse.

## Næste skridt

- [Tilføj koordinater til en CSV-fil](/guides/geokod-csv)
- [Grænser, kvoter og 429](/guides/graenser-og-429)
