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.
geef_project("schoolkeuzehulp")geef_bron("duo-onderwijsdata")geef_bron("pdok-bag")
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.
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).
How you could start7 steps
- 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
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
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
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
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
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
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 afstandThe 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 verblijfsobjectEvery 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}")
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
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).

Addresses and Buildings Register (BAG)
Every address and building in the Netherlands with year of construction, floor area and use. The backbone for anything linked at address level.
More to build
Earthquake tracker: every quake on the map, with municipality and homesMap
Neighbourhood safety monitor: nuisance and crime per neighbourhoodDashboard
Asylum reception dashboard: national and per municipalityDashboard
Climate risk check for an addressTool
Satellite before and after: see how your town or polder changedTool
Housing market dashboard for your municipalityDashboard