303,525 datasets indexed1,419 publishers
Open Data IndexThe index of open data about the Netherlands
Open Data Index›Build›School finder: primary schools around your address, with pupil numbers and school advice
ToolGetting startedEducationHousing and land

School finder: primary schools around your address, with pupil numbers and school advice

Type a postcode and house number and see every primary school within a radius, sorted by distance, with their pupil numbers over the years and what share of the children got a havo or vwo advice. Only open data from DUO and the BAG.

Your AI assistantOpen Data Index MCP on
The Open Data Index MCP server is on in your chat, so the assistant reads this project and its sources itself.
You paste this project's prompt
The assistant fetches from Open Data Index
  • geef_project("schoolkeuzehulp")
  • geef_bron("duo-onderwijsdata")
  • geef_bron("pdok-bag")
The assistant

I have the project, the tested calls and the pitfalls. I'll build a tool with DUO open education data and Addresses and Buildings Register (BAG). This is how it could look:

You build this yourself. Copy the prompt into Claude, ChatGPT or Cursor, with the MCP server on, or follow the steps below.

Follow the steps
8:15
Om de hoek
Schools nearby
Stadsplateau 1, Utrechtchange
≤ 1.5 kmPrimaryNearest ↓
13 schoolswithin 1.5 km
0500 m1 km1.5 km
Pupils 2015 → 2025 · advice for 2024
Kindercampus Molenpark
616 m182 → 152 pupils −16%
Advice havo or higher 59–73%most: vwo
KBS De Pijlstaart
701 m146 → 191 pupils +31%
Advice havo or higher 41–81%most: havo/vwo
KBS De Catharijnepoort
823 m324 → 157 pupils −52%
Advice havo or higher 52–81%most: havo/vwo
OBS De Kleine Dichter
1,124 m186 → 341 pupils +83%
Advice havo or higher 17–57%most: vmbo-gt/havo
SearchMapSavedCompare
Mockup

This is a mockup, not a working version, with the figures from our test run on 16 October 2026 for Stadsplateau 1 in Utrecht (DUO: pupils on 1 October 2015 and 2025, school advice for 2024). Turn the MCP server on with the address on For AI.

Who it's for

Parents looking for a primary school who want to look beyond the school's own website. Local journalists who want to show which schools grow or shrink. Municipalities and neighbourhood teams mapping the education on offer in a district. And anyone who wants to build a first map app with real government data.

What you get

A script (and then a small web app) that turns an address into a point, gives all 6,088 primary school locations a coordinate and shows the schools within a radius with distance, pupils in 2015 and 2025 and the share of havo and vwo advice in the latest reference year.

Test run on 16 October 2026 for the Utrecht city office (Stadsplateau 1, 3521 AZ): 13 primary schools within 1,500 metres. The nearest is Kindercampus Molenpark at 616 metres (182 pupils in 2015, 152 in 2025). The Vrije School Utrecht grew the most, from 325 to 437 pupils; KBS De Catharijnepoort almost halved, from 324 to 157. At ODBS Pieterskerkhof every child with an advice got havo or higher in reference year 2024.

Nationally, 1,356,909 children attended 6,096 primary school locations on 1 October 2025, against 1,443,066 at 6,584 locations in 2015 (6% fewer children). In the advice file for reference year 2024, 30,235 of the 43,860 cells hold a -1 (1 to 4 pupils): that is why the script shows a lower and upper bound per school, not false precision.

Pupils in primary education: 1.36 million in 2025/26, 10% less than in 1996/97

Pupils in primary education, 1996/97 – 2025/26, in million. The highest point was 2008/09 (1.55).

Source: DUO, Historische leerlingentellingen PO gemeente per jaar · opgehaald 25 oktober 2026 · selection: onderwijssoort BO; peildatum 1 oktoberOpen the table Single block

How you could start7 steps

  1. 1

    Turn the address into a point via the BAG

    Use PDOK's BAG WFS with a filter on postcode and house number, layer bag:verblijfsobject. Ask for srsName=EPSG:4326 to get longitude and latitude in degrees (GeoJSON puts longitude first). Don't use the PDOK Locatieserver: robots.txt of api.pdok.nl disallows /bzk/locatieserver/*. Pitfall: one house number can have several units (here 1 and 1T). Pick the one without house letter and addition, or let the user choose. The filter is FES 2.0 XML in the filter parameter; the postcode without a space and in capitals (3521AZ). The answer came back within a second.

    https://service.pdok.nl/lv/bag/wfs/v2_0?service=WFS&version=2.0.0&request=GetFeature&typeNames=bag:verblijfsobject&outputFormat=application/json&srsName=EPSG:4326&filter=<fes%3AFilter xmlns%3Afes%3D"http%3A%2F%2Fwww.opengis.net%2Ffes%2F2.0"><fes%3AAnd><fes%3APropertyIsEqualTo><fes%3AValueReference>postcode<%2Ffes%3AValueReference><fes%3ALiteral>3521AZ<%2Ffes%3ALiteral><%2Ffes%3APropertyIsEqualTo><fes%3APropertyIsEqualTo><fes%3AValueReference>huisnummer<%2Ffes%3AValueReference><fes%3ALiteral>1<%2Ffes%3ALiteral><%2Ffes%3APropertyIsEqualTo><%2Ffes%3AAnd><%2Ffes%3AFilter>
    {
    "type": "FeatureCollection",
    "numberMatched": 2,
    "name": "verblijfsobject",
    "features": [
    { "type": "Feature", "id": "verblijfsobject.be5cbd3b-…", "properties": { "identificatie": "0344010000163716", …, "openbare_ruimte": "Stadsplateau", "huisnummer": 1, "huisletter": "T", "toevoeging": "", "postcode": "3521AZ", "woonplaats": "Utrecht", "bouwjaar": 2014, … }, "geometry": { "type": "Point", "coordinates": [ 5.1078212966518013, 52.089758966081369 ] } },
    { "type": "Feature", "id": "verblijfsobject.9e217dfe-…", "properties": { "identificatie": "0344010000163715", "oppervlakte": 64446, …, "huisnummer": 1, "huisletter": "", "toevoeging": "", "postcode": "3521AZ", "woonplaats": "Utrecht", "bouwjaar": 2014, … }, "geometry": { "type": "Point", "coordinates": [ 5.1077492228137116, 52.089601288453508 ] } }
    ]
  2. 2

    Fetch the schools from DUO's address file

    DUO publishes every primary school location (6,088 on 16 October 2026) with name, address, municipality and denomination, in the CKAN datastore at onderwijsdata.duo.nl. Filter on GEMEENTENUMMER (text with a leading zero: "0344" for Utrecht, 97 locations) or fetch them all at once with limit=100000. The latter is better: someone living near a municipal border also wants to see the school just across it. Note the field name with a hyphen (HUISNUMMER-TOEVOEGING, "22-A", "26-28"): you have to extract the house number yourself. Without limit the datastore returns only 1,000 rows.

    https://onderwijsdata.duo.nl/api/3/action/datastore_search?resource_id=dcc9c9a5-6d01-410b-967f-810557588ba4&filters={"GEMEENTENUMMER"%3A"0344"}&fields=INSTELLINGSCODE,VESTIGINGSCODE,VESTIGINGSNAAM,STRAATNAAM,HUISNUMMER-TOEVOEGING,POSTCODE,DENOMINATIE&limit=2
    "records": [
        {
            "INSTELLINGSCODE": "28BF",
            "VESTIGINGSCODE": "28BF00",
            "VESTIGINGSNAAM": "School v/d Stichting Hindoe Onderwijs Shri Krishna school",
            "STRAATNAAM": "Van Lawick van Pabstlaan",
            "HUISNUMMER-TOEVOEGING": "3",
            "POSTCODE": "3453RA",
            "DENOMINATIE": "Hindoeïstisch"
        },
        …
    ],
    … "total": 97
  3. 3

    Give each school a coordinate from RIO

    The address file has no coordinates, but the register of institutions and programmes (RIO) does: the table onderwijslocaties (13,646 rows) has GPS_LATITUDE, GPS_LONGITUDE and the BAG id. Join on postcode plus house number; that worked for 5,836 of the 6,088 locations. So you don't need to query the BAG 6,088 times. A filter may be a list: {"POSTCODE": ["3521EX", "9306TC"]}.

    https://onderwijsdata.duo.nl/api/3/action/datastore_search?resource_id=a7e3f323-6e46-4dca-a834-369d9d520aa8&filters={"POSTCODE"%3A"3521EX"}
    "records": [
        {
            "_id": 5856,
            "ONDERWIJSLOCATIECODE": "106X637",
            "BAG_ID": "0344010000097484",
            "GPS_LATITUDE": 52.07973474,
            "GPS_LONGITUDE": 5.11133012,
            "STRAATNAAM": "Heycopplein",
            "HUISNUMMER": 1,
            "HUISNUMMERTOEVOEGING": null,
            "POSTCODE": "3521EX",
            "PLAATSNAAM": "Utrecht"
        }
    ],
  4. 4

    Catch the rest through the RIO link from location to building

    For locations without an address match there is the table relaties_onderwijslocatiegebruiken_vestigingserkenningen: VESTIGINGSCODE to ONDERWIJSLOCATIECODE, with start and end date. Only take rows without EINDDATUM: De Kleine Dichter (17RK00) moved on 1 August 2025 from building 106X370 to 106X637. 987 locations have more than one active building (annexes, buildings shared by several boards); then pick the main address, or show every building. With address and link together, 11 of the 6,088 locations were left without a coordinate.

    https://onderwijsdata.duo.nl/api/3/action/datastore_search?resource_id=c18ec7dd-aa4e-4b51-997c-782955f1aa38&filters={"VESTIGINGSCODE"%3A"17RK00"}
    "records": [
        {
            "_id": 6204,
            "ONDERWIJSLOCATIECODE": "106X370",
            "ONDERWIJSBESTUURID": "106B332",
            "VESTIGINGSCODE": "17RK00",
            "BEGINDATUM": "2021-08-01T00:00:00",
            "EINDDATUM": "2025-08-01T00:00:00"
        },
        {
            "_id": 6501,
            "ONDERWIJSLOCATIECODE": "106X637",
            "ONDERWIJSBESTUURID": "106B332",
            "VESTIGINGSCODE": "17RK00",
            "BEGINDATUM": "2025-08-01T00:00:00",
            "EINDDATUM": null
        }
    ],
  5. 5

    Pupils per school year: is the school growing or shrinking?

    The historical pupil counts (229,583 rows, 1996 to 2025) give the number of pupils on 1 October per location and per school year. SCHOOLJAAR is the year in which the school year starts: 2025 is 2025/26. Pitfall: mergers and moves. De Kleine Dichter had 125 pupils at 17RK00 and 345 at 17RK01 in 2024; in 2025 only 17RK00 exists, with 341. So look at all locations with the same INSTELLINGSCODE before you write 'tripled'. Summing per institution doesn't always work either: for Pieterskerkhof (17FL), 17FL01 (KC Cartesius) still counts 28 pupils in 2025, but that location is no longer in the address file.

    https://onderwijsdata.duo.nl/api/3/action/datastore_search?resource_id=0e7c3205-761b-41cb-9149-30576619a944&filters={"VESTIGINGSCODE"%3A"17RK00"}&fields=VESTIGINGSCODE,NAAM_VESTIGING,SCHOOLJAAR,AANTAL_LEERLINGEN&sort=SCHOOLJAAR desc&limit=3
    "records": [
        {
            "VESTIGINGSCODE": "17RK00",
            "NAAM_VESTIGING": "OBS De Kleine Dichter",
            "SCHOOLJAAR": 2025,
            "AANTAL_LEERLINGEN": 341
        },
        {
            "VESTIGINGSCODE": "17RK00",
            "NAAM_VESTIGING": "OBS De Kleine Dichter",
            "SCHOOLJAAR": 2024,
            "AANTAL_LEERLINGEN": 125
        },
        …
    ],
  6. 6

    School advice per school, with the suppressed cells

    The file 'Leerlingen bo en sbo per vestiging naar advies' gives the number of pupils per location, reference year and advice. The newest reference year in the API was 2024 (updated 9 January 2026). Three pitfalls. One: VESTIGINGSCODE here has only two characters ("00", "01"): prefix the INSTELLINGSCODE to join with the other files ("17RK" + "00"). Two: ADVIES is a number from 1 to 12 without labels in the API. DUO's explanation (toelichting-schooladviezen.pdf) lists the advice levels in the order of the ROD codes: vso, pro, vmbo-b, vmbo-b/k, vmbo-k, vmbo-k/gt, vmbo-gt, vmbo-gt/havo, havo, havo/vwo, vwo and no advice possible. That 1 = vso and 2 = pro fits the special primary schools (2024: code 2 2,618 times, code 1 430 times), but DUO never prints the numbers next to the labels. Three: -1 means 1 to 4 pupils. At this school 7 of the 13 cells are suppressed. Work with a lower and upper bound.

    https://onderwijsdata.duo.nl/api/3/action/datastore_search?resource_id=454c5dd4-6815-4b52-ae2f-b9eebc6afa6d&filters={"INSTELLINGSCODE"%3A"17RK"%2C"PEILJAAR"%3A2024}&fields=INSTELLINGSCODE,VESTIGINGSCODE,PEILJAAR,ADVIES,AANTAL_LEERLINGEN&sort=ADVIES
    total 13
    {"INSTELLINGSCODE": "17RK", "VESTIGINGSCODE": "01", "PEILJAAR": 2024, "ADVIES": 4, "AANTAL_LEERLINGEN": -1}
    {"INSTELLINGSCODE": "17RK", "VESTIGINGSCODE": "01", "PEILJAAR": 2024, "ADVIES": 6, "AANTAL_LEERLINGEN": 7}
    {"INSTELLINGSCODE": "17RK", "VESTIGINGSCODE": "00", "PEILJAAR": 2024, "ADVIES": 8, "AANTAL_LEERLINGEN": 7}
    {"INSTELLINGSCODE": "17RK", "VESTIGINGSCODE": "01", "PEILJAAR": 2024, "ADVIES": 8, "AANTAL_LEERLINGEN": 9}
    {"INSTELLINGSCODE": "17RK", "VESTIGINGSCODE": "01", "PEILJAAR": 2024, "ADVIES": 10, "AANTAL_LEERLINGEN": 17}
    {"INSTELLINGSCODE": "17RK", "VESTIGINGSCODE": "01", "PEILJAAR": 2024, "ADVIES": 11, "AANTAL_LEERLINGEN": 11}
    {"INSTELLINGSCODE": "17RK", "VESTIGINGSCODE": "00", "PEILJAAR": 2024, "ADVIES": 11, "AANTAL_LEERLINGEN": -1}
    …
  7. 7

    Look ahead with the forecast per school

    DUO projects pupil numbers per location up to 2045 (file 'Prognoses bo en sbo per vestiging'). Useful for parents who want to know whether a school will fill up, and for a column 'expected in 2030' next to the real count. Here too VESTIGINGSCODE has two characters and the forecast is a decimal number: round it when displaying. De Kleine Dichter: 341 pupils in 2025, expected 377 in 2026 and 407 in 2030, then falling to 285 in 2035.

    https://onderwijsdata.duo.nl/api/3/action/datastore_search?resource_id=f27c0d5c-59a0-41b9-b23f-f3a7ee03d4ba&filters={"INSTELLINGSCODE"%3A"17RK"%2C"JAAR"%3A[2026%2C2030%2C2035]}&sort=JAAR
    "records": [
        {
            "_id": 92581,
            "INSTELLINGSCODE": "17RK",
            "VESTIGINGSCODE": "00",
            "TYPE_PO": "BO",
            "JAAR": 2026,
            "PROGNOSEAANTALLEN": 377.08
        },
        {
            …
            "JAAR": 2030,
            "PROGNOSEAANTALLEN": 406.58
        },
        {
            …
            "JAAR": 2035,
            "PROGNOSEAANTALLEN": 285.36
        }
    ],

How the sources fit together

  • Addresses and Buildings Register (BAG)DUO open education datavia coördinaat (lengte, breedte in EPSG:4326) en afstand

    The BAG gives the point of the user's address, RIO gives the points of the schools. You compute the distance yourself with the haversine formula (in metres). To work in RD (EPSG:28992, metres, plain Pythagoras), ask the BAG without srsName and convert RIO's GPS points. As the crow flies is not a walking route: for a real walking or cycling distance you need a route planner.

  • DUO open education dataAddresses and Buildings Register (BAG)via BAG_ID van de onderwijslocatie (RIO) naar verblijfsobject

    Every education location in RIO has a BAG_ID (for example 0344010000097484 for De Kleine Dichter). With it you fetch the construction year and floor area of the school building from the BAG, for example to show m² per pupil.

  • DUO open education dataDUO open education datavia INSTELLINGSCODE + VESTIGINGSCODE ("17RK" + "00" = "17RK00")

    The address file and the pupil counts use the full six-character code, the advice and forecast files only the last two. Build one six-character key everywhere before joining.

Let AI build it

Paste this prompt into Claude, ChatGPT, Cursor or Lovable. It names the real endpoints, the join keys and the pitfalls, so the assistant writes working code straight away. Always check the result against the source itself.

Build a small web app (one HTML file with JavaScript, or Next.js if you prefer) where I type a postcode and house number and get a list and map of every primary school within a radius I choose (default 1,500 metres).

Data, all open, no key:
1. Address to point: PDOK's BAG WFS, https://service.pdok.nl/lv/bag/wfs/v2_0 with service=WFS&version=2.0.0&request=GetFeature&typeNames=bag:verblijfsobject&outputFormat=application/json&srsName=EPSG:4326 and a FES 2.0 filter in the filter parameter (PropertyIsEqualTo on postcode and on huisnummer, together in fes:And). Postcode without a space, in capitals. GeoJSON: coordinates = [longitude, latitude]. Pick the feature without huisletter and toevoeging if there are several. Do NOT use the PDOK Locatieserver (robots.txt disallows /bzk/locatieserver/*).
2. DUO CKAN datastore: https://onderwijsdata.duo.nl/api/3/action/datastore_search?resource_id=…&filters={json}&fields=…&limit=100000
   - Primary school locations with address: dcc9c9a5-6d01-410b-967f-810557588ba4 (6,088 rows; fields INSTELLINGSCODE, VESTIGINGSCODE, VESTIGINGSNAAM, STRAATNAAM, HUISNUMMER-TOEVOEGING, POSTCODE, PLAATSNAAM, GEMEENTENUMMER, DENOMINATIE, INTERNETADRES).
   - RIO education buildings with GPS: a7e3f323-6e46-4dca-a834-369d9d520aa8 (ONDERWIJSLOCATIECODE, BAG_ID, GPS_LATITUDE, GPS_LONGITUDE, POSTCODE, HUISNUMMER).
   - RIO location to building: c18ec7dd-aa4e-4b51-997c-782955f1aa38 (VESTIGINGSCODE, ONDERWIJSLOCATIECODE, BEGINDATUM, EINDDATUM).
   - Pupils per location per school year: 0e7c3205-761b-41cb-9149-30576619a944 (VESTIGINGSCODE, SCHOOLJAAR, AANTAL_LEERLINGEN; 1996 to 2025).
   - School advice per location: 454c5dd4-6815-4b52-ae2f-b9eebc6afa6d (INSTELLINGSCODE, VESTIGINGSCODE, PEILJAAR, ADVIES, AANTAL_LEERLINGEN; newest reference year 2024).
   - Forecast per location up to 2045: f27c0d5c-59a0-41b9-b23f-f3a7ee03d4ba (INSTELLINGSCODE, VESTIGINGSCODE, JAAR, PROGNOSEAANTALLEN).

What you need to know:
- Without limit the datastore returns 1,000 rows. Set limit=100000 and check that the number of records equals result.total. The advice file has 43,860 rows for one reference year: with limit=32000 you silently miss a quarter.
- filters is JSON in the URL (URL-encode it). A value may be a list: {"INSTELLINGSCODE": ["17RK", "17FL"]}. datastore_search_sql is not allowed.
- Coordinates: join location to RIO building on postcode + the leading number in HUISNUMMER-TOEVOEGING ("22-A" becomes 22). If that fails, use the link table with an empty EINDDATUM. Distance with haversine.
- VESTIGINGSCODE has six characters in the address and pupil files ("17RK00"), but two in the advice and forecast files ("00"): prefix INSTELLINGSCODE.
- ADVIES is a number: 1 vso, 2 pro, 3 vmbo-b, 4 vmbo-b/k, 5 vmbo-k, 6 vmbo-k/gt, 7 vmbo-gt, 8 vmbo-gt/havo, 9 havo, 10 havo/vwo, 11 vwo, 12 no advice possible (order from DUO's explanation; DUO does not print the numbers itself, say so in the app).
- AANTAL_LEERLINGEN = -1 means 1 to 4 pupils (privacy). Don't add it as a number. Show a lower and upper bound ("41% to 81% havo or higher") and grey out schools where most cells are suppressed.
- SCHOOLJAAR 2025 is school year 2025/26, counted on 1 October. Mergers and moves cause jumps: look at other locations with the same INSTELLINGSCODE and say so instead of 'tripled'.
- Municipality numbers differ per file: "0344" (text) in the address file, 344 (number) in the pupil counts. For CBS it becomes GM0344.

Output: at the top the address found. Below that a map (Leaflet with the PDOK BRT background map) with a dot per school, and a sortable table: distance, name (link to INTERNETADRES), denomination, pupils 2015 and 2025 with a sparkline, forecast 2030, share havo or higher (lower and upper bound) and the most common advice. At the bottom the attribution: "DUO open education data (CC BY), BAG via PDOK", with the reference dates.

Try it yourself

The core of the project in a small script. Needs: Python 3.9+ en requests (de Mac-Python met LibreSSL werkt hier: onderwijsdata.duo.nl en service.pdok.nl gaven geen TLS-fout). Tested on 2026-10-16; below is the real output of that run.

# Basisscholen rond een adres: leerlingen door de tijd en schooladviezen.
# Bronnen: BAG (PDOK WFS) voor het adres, DUO open onderwijsdata (CKAN datastore) voor de scholen.
import math, re, collections, requests

UA = {"User-Agent": "mijn-schoolkeuzehulp/1.0 (+jij@voorbeeld.nl)"}
POSTCODE, HUISNUMMER, STRAAL_M = "3521AZ", 1, 1500
DUO = "https://onderwijsdata.duo.nl/api/3/action/datastore_search"
VESTIGINGEN = "dcc9c9a5-6d01-410b-967f-810557588ba4"   # alle vestigingen bo, met adres
LOCATIES = "a7e3f323-6e46-4dca-a834-369d9d520aa8"      # RIO onderwijslocaties, met GPS
RELATIES = "c18ec7dd-aa4e-4b51-997c-782955f1aa38"      # RIO vestiging -> onderwijslocatie
LEERLINGEN = "0e7c3205-761b-41cb-9149-30576619a944"    # historische leerlingentellingen per vestiging
ADVIES = "454c5dd4-6815-4b52-ae2f-b9eebc6afa6d"        # leerlingen per vestiging naar schooladvies
# Volgorde van de DUO-toelichting (ROD-codes 1 vso, 10 pro, 20 vmbo bl ... 80 geen advies mogelijk)
ADVIEZEN = {1: "vso", 2: "pro", 3: "vmbo-b", 4: "vmbo-b/k", 5: "vmbo-k", 6: "vmbo-k/gt", 7: "vmbo-gt",
            8: "vmbo-gt/havo", 9: "havo", 10: "havo/vwo", 11: "vwo", 12: "geen advies mogelijk"}

def duo(resource, **p):
    j = requests.get(DUO, params={"resource_id": resource, "limit": 100000, **p}, headers=UA, timeout=120).json()["result"]
    assert len(j["records"]) == j["total"], "niet alles opgehaald: verhoog limit of pagineer met offset"
    return j["records"]

# 1. Adres naar punt via de BAG-WFS (niet de Locatieserver: die is in robots.txt uitgesloten)
fes = ('<fes:Filter xmlns:fes="http://www.opengis.net/fes/2.0"><fes:And>'
       f'<fes:PropertyIsEqualTo><fes:ValueReference>postcode</fes:ValueReference><fes:Literal>{POSTCODE}</fes:Literal></fes:PropertyIsEqualTo>'
       f'<fes:PropertyIsEqualTo><fes:ValueReference>huisnummer</fes:ValueReference><fes:Literal>{HUISNUMMER}</fes:Literal></fes:PropertyIsEqualTo>'
       '</fes:And></fes:Filter>')
bag = requests.get("https://service.pdok.nl/lv/bag/wfs/v2_0", headers=UA, timeout=60, params={
    "service": "WFS", "version": "2.0.0", "request": "GetFeature", "typeNames": "bag:verblijfsobject",
    "outputFormat": "application/json", "srsName": "EPSG:4326", "filter": fes}).json()["features"]
vo = next((f for f in bag if not f["properties"]["huisletter"] and not f["properties"]["toevoeging"]), bag[0])
lon, lat = vo["geometry"]["coordinates"]
p = vo["properties"]
print(f"Adres: {p['openbare_ruimte']} {p['huisnummer']}, {p['postcode']} {p['woonplaats']} ({lat:.5f}, {lon:.5f})")

def afstand(lat1, lon1, lat2, lon2):   # haversine, in meters
    f1, f2 = math.radians(lat1), math.radians(lat2)
    a = math.sin((f2 - f1) / 2) ** 2 + math.cos(f1) * math.cos(f2) * math.sin(math.radians(lon2 - lon1) / 2) ** 2
    return 2 * 6371000 * math.asin(math.sqrt(a))

# 2. Alle vestigingen met een coördinaat: eerst op postcode + huisnummer, anders via de RIO-relatie
vest = duo(VESTIGINGEN, fields="INSTELLINGSCODE,VESTIGINGSCODE,VESTIGINGSNAAM,STRAATNAAM,HUISNUMMER-TOEVOEGING,POSTCODE,DENOMINATIE")
loc = duo(LOCATIES, fields="ONDERWIJSLOCATIECODE,POSTCODE,HUISNUMMER,GPS_LATITUDE,GPS_LONGITUDE")
op_adres = {(l["POSTCODE"], l["HUISNUMMER"]): l for l in loc}
op_code = {l["ONDERWIJSLOCATIECODE"]: l for l in loc}
via_rio = {r["VESTIGINGSCODE"]: op_code.get(r["ONDERWIJSLOCATIECODE"]) for r in duo(RELATIES) if r["EINDDATUM"] is None}
dichtbij = []
for s in vest:
    nr = re.match(r"\d+", s["HUISNUMMER-TOEVOEGING"] or "")
    l = op_adres.get((s["POSTCODE"], int(nr.group()) if nr else None)) or via_rio.get(s["VESTIGINGSCODE"])
    if l and l["GPS_LATITUDE"]:
        m = afstand(lat, lon, l["GPS_LATITUDE"], l["GPS_LONGITUDE"])
        if m <= STRAAL_M:
            dichtbij.append((m, s))
dichtbij.sort(key=lambda x: x[0])
print(f"{len(vest)} vestigingen in het basisonderwijs, {len(dichtbij)} binnen {STRAAL_M} m\n")

# 3. Leerlingen per schooljaar (SCHOOLJAAR = jaar waarin het schooljaar begint, telling 1 oktober)
brins = sorted({s["INSTELLINGSCODE"] for _, s in dichtbij})
lijst = str(brins).replace("'", '"')
ll = collections.defaultdict(dict)
for r in duo(LEERLINGEN, filters='{"INSTELLINGSCODE": %s}' % lijst):
    ll[r["VESTIGINGSCODE"]][r["SCHOOLJAAR"]] = r["AANTAL_LEERLINGEN"]

# 4. Schooladviezen in het nieuwste peiljaar. VESTIGINGSCODE is hier maar 2 tekens ('00'); -1 = 1 tot 4 leerlingen
adv = duo(ADVIES, filters='{"INSTELLINGSCODE": %s}' % lijst)
jaar = max(r["PEILJAAR"] for r in adv)
per = collections.defaultdict(lambda: collections.defaultdict(list))
for r in adv:
    if r["PEILJAAR"] == jaar:
        per[r["INSTELLINGSCODE"] + r["VESTIGINGSCODE"]][r["ADVIES"]].append(r["AANTAL_LEERLINGEN"])

print(f"{'afstand':>7}  {'school':32} {'2015':>4} {'2025':>4}  havo of hoger {jaar}")
for m, s in dichtbij:
    k = s["VESTIGINGSCODE"]
    reeks = ll[k]
    cellen = [(a, n) for a, ns in per.get(k, {}).items() for n in ns]
    hoog = sum(n for a, n in cellen if a >= 9 and n > 0)
    rest = sum(n for a, n in cellen if a < 9 and n > 0)
    h1 = sum(1 for a, n in cellen if a >= 9 and n == -1)   # afgeschermde cellen: 1 tot 4 leerlingen
    r1 = sum(1 for a, n in cellen if a < 9 and n == -1)
    if hoog + rest + h1 + r1 == 0:
        tekst = "geen adviezen"
    else:   # onder- en bovengrens, want -1 kan 1, 2, 3 of 4 zijn
        laag = (hoog + h1) / (hoog + h1 + rest + 4 * r1)
        hoogst = (hoog + 4 * h1) / (hoog + 4 * h1 + rest + r1)
        tekst = f"{laag:.0%}" if laag == hoogst else f"{laag:.0%} tot {hoogst:.0%}"
        meest = max(cellen, key=lambda c: c[1])
        tekst = f"{tekst:12} (meest: {ADVIEZEN[meest[0]]})" if meest[1] > 0 else tekst
    print(f"{m:6.0f}m  {s['VESTIGINGSNAAM'][:32]:32} {reeks.get(2015, '-'):>4} {reeks.get(2025, '-'):>4}  {tekst}")
Output
Adres: Stadsplateau 1, 3521AZ Utrecht (52.08960, 5.10775)
6088 vestigingen in het basisonderwijs, 13 binnen 1500 m

afstand  school                           2015 2025  havo of hoger 2024
   616m  Kindercampus Molenpark            182  152  59% tot 73%  (meest: vwo)
   701m  KBS De Pijlstaart                 146  191  41% tot 81%  (meest: havo/vwo)
   823m  KBS De Catharijnepoort            324  157  52% tot 81%  (meest: havo/vwo)
  1124m  Openbare Basisschool De Kleine D  186  341  17% tot 57%  (meest: vmbo-gt/havo)
  1133m  Christelijke Basisschool De Brug  318  285  65% tot 72%  (meest: vwo)
  1207m  ODBS Pieterskerkhof               190  219  100%         (meest: havo/vwo)
  1215m  Openbare Basisschool De Koekoek   340  336  76% tot 93%  (meest: vwo)
  1283m  Kathedrale Koorschool              75   53  43% tot 82%  (meest: vwo)
  1295m  Vrije School Utrecht              325  437  78%          (meest: vwo)
  1346m  Asch van Wijckschool              224  201  49% tot 69%  (meest: vwo)
  1411m  Basisschool Agatha Snellen        220  211  75% tot 92%  (meest: vwo)
  1465m  Basisschool Anne Frank            191  186  20% tot 80%
  1472m  Johan de Witt                       -  207  55%          (meest: havo/vwo)

Where it goes wrong

  • Without limit the datastore returns 1,000 rows and silently cuts off. With limit=32000 the 2024 advice file returned only 32,000 of 43,860 rows, and it looked as if 282 children got a vso advice instead of 430. Set limit=100000 and check len(records) == total.
  • -1 is not a number but '1 to 4 pupils'. In reference year 2024, 69% of the advice cells are suppressed (30,235 of 43,860). Counting only the known cells overestimates large categories (vwo came out at 25% that way) and hides small ones (pro at regular primary schools). Show a range.
  • ADVIES 1 to 12 has no labels in the API. The order vso, pro, vmbo-b … vwo, no advice possible comes from DUO's explanation (ROD codes 1, 10, 20 … 80) and fits the special primary schools, but DUO doesn't literally confirm the numbering. Say so, and check again when the file changes.
  • Two kinds of location code: "17RK00" in the address and pupil files, "00" in the advice and forecast files. Joining on VESTIGINGSCODE alone links every main location in the country to each other.
  • Mergers, moves and closures cause jumps in the series (De Kleine Dichter: 125 to 341 pupils at 17RK00 because 17RK01 was merged into it). Summing per institution isn't always right either (Pieterskerkhof and KC Cartesius). Show the series per location and flag it when another location of the same institution stopped.
  • One address can have several units (Stadsplateau 1 and 1T). Don't blindly take the first result. And use the BAG WFS or the OGC API, not the Locatieserver: robots.txt of api.pdok.nl lists it as a disallowed path.
  • A nearby school is not necessarily a school your child can get into. Many municipalities have central admission or priority rules, and straight-line distance is not a walking route. Show distance as an indication and link to the school's website.

Take it further

  • Add test results: the file 'Referentieniveau per toets en vestiging' (89920163-8b28-4e1a-b322-c3bb90f74f41) gives per school how many pupils reached 1F, 2F and 1S for language and maths. Use that rather than the average final score: in reference year 2024 schools used six different transition tests (LIB 3,015 rows, IEP 2,676, ROUTE 8 562, DIA 360, AMN 112, DOE 39), each with its own scale.
  • Where do the children come from? 'Leerlingen po per vestiging naar postcode4' (402c5a69-eea8-41b4-b8ad-ecac301c28a8) shows per school which postcode areas its pupils come from. That tells you whether a school really is a neighbourhood school and how far parents travel.
  • School building from the BAG: with the BAG_ID from RIO you fetch construction year and floor area and compute m² per pupil. Or colour the map by the school weighting ('Schoolgewicht per schoolvestiging', 59383f9c-2b0b-4aae-97f9-dab8d92cd873), so advice can be compared more fairly.
  • The same for secondary school: the address file for secondary education (5187f8d5-ff9c-4284-8e06-4311f0354956) and the secondary files in the same catalogue. Then show for each primary school which levels the children went to and which nearby secondary schools offer those levels.

The sources2

DUO open education data

Dienst Uitvoering Onderwijs (ministerie van OCW)Netherlands, school location

Every school and programme with its address, and per school location the number of pupils, school advice, end-of-primary test scores, exam results and forecasts. From primary school to university. DUO publishes it as downloads and as an API (56 datasets with 274 tables in the datastore on 16 October 2026).

OpenAPIDownloadEducationPopulation
Up to date
updated 10 hours ago

Addresses and Buildings Register (BAG)

Kadaster, via PDOKNetherlands, address and building

Every address and building in the Netherlands with year of construction, floor area and use. The backbone for anything linked at address level.

OpenMap layerAPIDownloadHousing and land
Reachable
daily

More to build