Which Trees Stand on Your Site? Read Zürich's Baumkataster in Python Before Sketching
Read Zürich's CC0 tree cadastre in LV95 with standard-library Python: list trees near a site, flag 8 m placeholder crowns, test a footprint first.
Before the first sketch, ask the city which trees it already knows. Grün Stadt Zürich keeps the Baumkataster, a register of each tree’s position, species, crown and planting year. It is published under CC0 and updated weekly. On 30 October 2026 its web feature service answered with 81,142 registered trees. That is not every tree in the city. According to Grün Stadt Zürich, the register covers all municipal street trees, plus trees in selected public parks, private trees and the fruit-tree inventory.
The reason to open it now is six months old and still live. On 15 April 2026 the Stadtrat adopted the BZO partial revision «Baumerhalt». Felling a tree with a trunk circumference from 100 cm now needs a permit. The city’s Bauberatung writes that the rules “sind per sofort in Baugesuchen zu berücksichtigen” (negative Vorwirkung, § 234 PBG). The first question on a Zürich site is no longer “may I fell it” but “what stands there”.
One fact keeps the script small. Per swisstopo, the LV95 origin near Bern carries E = 2’600’000 m and N = 1’200’000 m, and “von dort aus werden die Koordinaten in Metern gezählt”. So “how far is the tree” is Pythagoras. You need no haversine and no projection library, just one math.hypot.
The quiet heroes sit underneath the numbers. Since 2001, stem positions have been surveyed tachymetrically, to an accuracy between under 1 cm and 30 cm. The data reaches you through the city’s GIS-Zentrum, Geomatik + Vermessung. Somebody set up a tripod beside those trunks. That is why your script can print 3.2 m and mean it.
The Tool: the Baumkataster, served by the Stadt Zürich WFS, plus Python’s standard library (json, math, sys). There is nothing to pip install. A bounding box around the site used here returns 34 KB and 60 trees. Drop the BBOX and you get the whole city, 46.6 MB, and the script prints identical results. The skills carry over to every Swiss dataset in EPSG:2056.
Setup:
# Python 3.10+, standard library only - nothing to pip install
mkdir trees
cd trees
# on Windows PowerShell type curl.exe instead of curl
curl -o idaplatz.geojson "https://www.ogd.stadt-zuerich.ch/wfs/geoportal/Baumkataster?SERVICE=WFS&VERSION=1.1.0&REQUEST=GetFeature&TYPENAME=baumkataster_baumstandorte&OUTPUTFORMAT=application/vnd.geo%2Bjson&SRSNAME=EPSG:2056&BBOX=2681206,1247540,2681326,1247660,EPSG:2056"
# save the program below as trees_near_site.py, then:
python -I trees_near_site.py idaplatz.geojsonFirst steps:
- Run the Setup block. A file called
idaplatz.geojsonof about 34 KB appears. - Save the program below and run it. It prints
Baumkataster file: 60 trees, thenSite E=2681266 N=1247600, radius 50 m -> 42 trees, then the table, nearest tree first. - Set
SITEto your parcel’s LV95 point and move the URL’s BBOX around it. The geo.admin.ch search returns EPSG:2056; “Idaplatz 1, 8003 Zürich” resolves to E 2681294.1 / N 1247579.5. - Replace the two rectangles in
FOOTPRINTSwith your own first massing idea.
The code, explained:
# trees_near_site.py - which city trees stand within R metres of a site, how much
# canopy that is, and which crowns reach into a footprint. Standard library only.
# Data: Baumkataster, Stadt Zuerich (CC0), GeoJSON in LV95 (EPSG:2056) = metres.
import csv, json, math, sys
GEOJSON = sys.argv[1] # path to the downloaded GeoJSON
SITE = (2681266.0, 1247600.0) # LV95 E, N - Idaplatz, 8003 Zuerich
RADIUS = 50.0 # search radius in metres
DEFAULT_CROWN = 8 # the cadastre writes 8 m when the crown is unknown
def rect(e, n, w, h): # a w x h footprint with its SW corner at (e, n)
return [(e, n), (e + w, n), (e + w, n + h), (e, n + h)]
FOOTPRINTS = {"A pavilion 10 x 14 m as sketched": rect(2681258, 1247594, 10, 14),
"B same pavilion, 13 m south, 1 m east": rect(2681259, 1247581, 10, 14)}
def dist(a, b):
return math.hypot(a[0] - b[0], a[1] - b[1])
def dist_point_segment(p, a, b):
"""Distance from point p to the segment a-b."""
dx, dy = b[0] - a[0], b[1] - a[1]
if dx == 0 and dy == 0:
return dist(p, a)
t = ((p[0] - a[0]) * dx + (p[1] - a[1]) * dy) / (dx * dx + dy * dy)
t = max(0.0, min(1.0, t))
return dist(p, (a[0] + t * dx, a[1] + t * dy))
def point_in_polygon(p, poly):
"""Ray casting: odd number of edge crossings to the right of p = inside."""
inside, n = False, len(poly)
for i in range(n):
(x1, y1), (x2, y2) = poly[i], poly[(i + 1) % n]
if (y1 > p[1]) != (y2 > p[1]):
if p[0] < x1 + (p[1] - y1) * (x2 - x1) / (y2 - y1):
inside = not inside
return inside
def dist_point_polygon(p, poly):
"""0.0 inside the polygon, otherwise the distance to the nearest edge."""
if point_in_polygon(p, poly):
return 0.0
return min(dist_point_segment(p, poly[i], poly[(i + 1) % len(poly)]) for i in range(len(poly)))
with open(GEOJSON, encoding="utf-8") as f:
fc = json.load(f)
assert fc["crs"]["properties"]["name"].endswith("EPSG::2056"), "expected LV95 metres"
near = []
for feat in fc["features"]:
e, n = feat["geometry"]["coordinates"]
d = dist((e, n), SITE)
if d <= RADIUS:
near.append((d, e, n, feat["properties"]))
near.sort(key=lambda r: r[0])
print(f"Baumkataster file: {len(fc['features'])} trees")
print(f"Site E={SITE[0]:.0f} N={SITE[1]:.0f}, radius {RADIUS:.0f} m -> {len(near)} trees\n")
print(f"{'dist':>5} {'E':>11} {'N':>11} {'crown':>6} {'year':>5} {'fix':<9} species")
canopy = defaults = 0
for d, e, n, q in near:
crown = q["kronendurchmesser"]
canopy += math.pi * (crown / 2) ** 2
defaults += crown == DEFAULT_CROWN
fix = "measured" if q["genauigkeit"].startswith("Eingemessen") else "screen"
print(f"{d:5.1f} {e:11.1f} {n:11.1f} {crown:5d}{'*' if crown == DEFAULT_CROWN else ' '} "
f"{q['pflanzjahr'] or '-':>5} {fix:<9} {q['baumnamelat']}")
disc = math.pi * RADIUS ** 2
years = [q["pflanzjahr"] for *_, q in near if q["pflanzjahr"]]
print(f"\nCanopy, sum of crown discs (overlaps not merged): {canopy:,.0f} m2 = {100 * canopy / disc:.0f} % of the {disc:,.0f} m2 search disc")
print(f"* = crown exactly {DEFAULT_CROWN} m, the cadastre's placeholder for 'unknown': {defaults} of {len(near)}")
print(f"Planting year: oldest {min(years)}, newest {max(years)}, unknown {len(near) - len(years)}")
print(f"Positions surveyed on site: {sum(1 for *_, q in near if q['genauigkeit'].startswith('Eingemessen'))} of {len(near)}")
print("Trunk circumference: NOT in the cadastre. The 100 cm permit rule needs a tape measure on site.")
for name, poly in FOOTPRINTS.items():
print(f"\nFootprint {name}:")
trunks = crowns = 0
for d, e, n, q in near:
gap, r = dist_point_polygon((e, n), poly), q["kronendurchmesser"] / 2
if gap == 0:
trunks += 1
print(f" TRUNK INSIDE {q['baumnamelat']} ({q['pflanzjahr'] or '?'}), crown {2 * r:.0f} m")
elif gap < r:
crowns += 1
print(f" crown overlaps {q['baumnamelat']}: trunk {gap:.1f} m outside the edge, crown radius {r:.1f} m")
print(f" -> {trunks} trunk(s) inside, {crowns} further crown(s) overhanging")Lines 6–9: the four knobs. These lines hold the file path, the site point in metres, the radius and the 8 m constant. A constant is a named number you set once, so the next reader knows what the 8 means. SITE is the rounded centroid of the 17 cadastre trees whose street field reads Idaplatz. The 10 × 14 m pavilion is hypothetical.
Lines 17–27: distance, twice. dist is Pythagoras, √(ΔE² + ΔN²). You can check the nearest tree by hand: √((2681262.862 − 2681266)² + (1247599.325 − 1247600)²) = 3.21 m. dist_point_segment projects p onto the edge a–b as a fraction t, where 0 is at a and 1 is at b. It clamps t to the range 0 to 1 and measures to that point. The clamp is the whole idea: without it you would measure to a wall that runs on into the street.
Lines 29–43: inside or outside. point_in_polygon casts a ray from the trunk to the right and counts the footprint edges it crosses. An odd count means inside. This works for any simple polygon, not only rectangles. dist_point_polygon returns 0.0 inside, and otherwise the distance to the nearest edge.
Lines 45–55: load, check, filter. json.load turns the file into dictionaries and lists. The assert guards against the classic beginner mistake. If you edit the URL and lose SRSNAME, the coordinates may not be metres, and every distance will be quietly wrong. The loop then keeps the 42 trees within 50 m, nearest first. It is the list move PAZ taught in Arrays for architects.
Lines 57–75: the report, and reading the metadata. Each crown counts as a disc, π·(d/2)². The summary prints:
Canopy, sum of crown discs (overlaps not merged): 1,086 m2 = 14 % of the 7,854 m2 search disc
* = crown exactly 8 m, the cadastre's placeholder for 'unknown': 9 of 42
Planting year: oldest 1970, newest 2024, unknown 11
Positions surveyed on site: 31 of 42
Trunk circumference: NOT in the cadastre. The 100 cm permit rule needs a tape measure on site.Read the dataset page before you trust the crown column. Grün Stadt Zürich writes that crown diameters are incomplete, irregularly updated and “nur geschätzt”, and that 8 m is used wherever no diameter exists. The star marks those rows: 9 of 42 here, and 13,815 city-wide (17 %). Eight metres is the cadastre’s polite shrug. The 14 % adds up discs with the overlaps left in, so it is not a measured area of shade. There is no circumference field at all, so the 100 cm rule cannot be answered from your desk.
Lines 77–88: the footprint test. gap is the trunk’s distance to the footprint and r is the crown radius. A gap of 0 means the trunk stands inside; a gap below r means the crown reaches over the edge. Footprint A has one trunk inside, an Acer pseudoplatanus planted in 2001. It also has a Gleditsia whose trunk stands 0.1 m outside the edge, with its crown overhanging. The script prints -> 1 trunk(s) inside, 1 further crown(s) overhanging. Footprint B, 13 m south and 1 m east, prints -> 0 trunk(s) inside, 0 further crown(s) overhanging. A grid search in 1-m steps found 1,380 clear positions within 40 m, and B is the one nearest the sketch. Plainly put, the trade-off is this: the test is only as good as a crown column the city itself calls estimated. A clear result means “go and measure”, not “go and build”.
←TODAY: Since 15 April 2026 every Zürich Baugesuch meets the Baumerhalt rules, and the city publishes 81,142 registered trees under CC0, weekly.
→3012: The tree enters the model before the wall does, at its true position, with its uncertainty printed beside it.
Fulcrum: Open metric data tells you where to look; only the tape measure tells you what the permit needs.
Atelier: For a small Zürich studio the shift is about sequence. Since April, a tree inside the sketch is a permit question, and the cadastre shows you that tree before anyone falls in love with the massing. An AI massing assistant will happily place a pavilion on a sycamore maple. A 34 KB file does not care how good the render looks. The Monday move is one line on the project-start checklist: run trees_near_site.py with the parcel BBOX, file the printout, and book a tape-measure visit for every tree whose crown reaches the footprint.
Hack: Send the crowns to Rhino as circles, measured from the site point so the geometry sits near the origin, where Rhino is most precise. Add these lines to the end of the program and predict the result before you run it. crowns_rhino.txt should hold 42 lines, and the first should begin _Circle -3.14,-0.68,0: the 3.21 m tree from the hand check. In Rhino 8, ReadCommandFile should replay the file into a metre model, ready for a Grasshopper definition.
# one Rhino command per crown, site point = model origin
with open("crowns_rhino.txt", "w") as out:
for d, e, n, q in near:
out.write(f"_Circle {e - SITE[0]:.2f},{n - SITE[1]:.2f},0 {q['kronendurchmesser'] / 2:.1f}\n")From where I sit, in the late 2070s, the datasets that survived were the boring ones: plain text, an open licence, and a page saying what the numbers mean. When you pick a site-analysis stack this quarter, ask whether a 25-year-old could still open the file once the vendor is gone. Here the answer is yes. Archive the raw GeoJSON with each project to keep it that way.
Learn-it:
- Dataset, licence and the crown caveat: data.stadt-zuerich.ch: Baumkataster
- The WFS endpoint: ogd.stadt-zuerich.ch/wfs/geoportal/Baumkataster
- Why every coordinate is already metres: swisstopo: Das Schweizer Koordinatensystem LV95
- The rules your Baugesuch now meets: Stadt Zürich: Baumschutz / Baumerhalt and Freiraumberatung
- PAZ note: lists are Day 1 of our Python sprint, in Arrays for architects. PAZ Academy teaches Python and Grasshopper 2 to offices and supplies Rhino 8 licences. The next step is reading these circles into a definition.
Save trees_near_site.py with your parcel’s BBOX and run it before the first sketch of every Zürich project.
PAZ Kaffi · multidisciplinary editorial, led by PAZ Academy