Urban Morphology Explained

On this page

Problem statement

Urban morphology is the study of the physical form of cities: the size and shape of buildings, how they sit on their plots, how plots group into blocks, and how blocks are threaded by streets. It used to be a discipline of drawings and fieldwork. Open footprint and street data have turned it into a quantitative one, with libraries such as momepy computing hundreds of measures for every building in a city.

The risk is measuring everything and understanding little. Most measures are variations on a few ideas, many depend as much on how the data was drawn as on the city, and all of them depend on the scale and the unit of land they are computed over. Knowing the handful of dimensions that matter, and which element each belongs to, is what makes the numbers readable.

Quick answer

Morphology describes four nested elements, each with its own characteristic measures:

Element Unit in the data Typical measures
building footprint polygon + height area, height, shape, shared walls
plot cadastral parcel or tessellation cell coverage, floor area ratio
block area enclosed by streets size, perimeter, buildings per block
street network segment and junction width, orientation, intersection density
import numpy as np, geopandas as gpd, momepy

b = gpd.read_parquet("denhaag_bag.parquet")                    # footprints guide, EPSG:28992
b = b[b.is_valid & (b.area > 10)].reset_index(drop=True)
b["height"] = (b.b3_h_70p - b.b3_h_maaiveld).clip(lower=2.5)
cells = momepy.morphological_tessellation(b, clip=momepy.buffered_limit(b, buffer=100))

print(f"{len(b):,} buildings in central Den Haag")
print(f"building: median footprint {b.area.median():.0f} m², median height {b.height.median():.1f} m")
print(f"building: median shared-wall share "
      f"{(momepy.shared_walls(b) / b.length).median():.0%} of the perimeter")
print(f"plot (tessellation): median cell {cells.area.median():.0f} m², "
      f"median coverage {(b.area / cells.area.reindex(b.index)).median():.0%}")
19,596 buildings in central Den Haag
building: median footprint 86 m², median height 10.2 m
building: median shared-wall share 34% of the perimeter
plot (tessellation): median cell 207 m², median coverage 43%

Four numbers at two scales already describe the fabric: small, mid-rise buildings, a third of whose perimeter is a wall shared with a neighbour, each covering a little under half of the ground around it — terraces and closed blocks with gardens and courtyards behind.

Pick the element, find the best source, measure a few things

1. Decide which element the question is about

"How dense is the city" is a plot or block question; "how walkable" a block and street question; "what kind of buildings" a building question. Measures from the wrong element answer a different question.

2. Get each element from the best available source

Buildings from a cadastre or OSM/Overture; plots from a cadastre or a tessellation; blocks by polygonising streets; streets from OSM.

3. Compute a few measures per element

Size and one or two shape or relation measures per element are usually enough. Many morphometric indices are redundant; check correlations before keeping both of a pair.

4. Add context

A building's neighbourhood — the measures averaged over the buildings around it — describes the fabric better than the building alone.

5. Report the scale and the source

Every measure depends on the footprint source and the unit of land. They are part of the result.

The four nested elements of urban form with their data units and typical measures. four elements of formstreetnetwork segments and junctionsorientation, intersectionsblockfaces of the street networksize, perimeterplotparcel or tessellation cellcoverage, FARbuildingfootprint + heightsize, shape, shared wallsEach element changes at its own speed: buildings in decades, streets rarely spatialworkflow.io
Four elements, each with its own unit and its own measures.

Measures at each element of Den Haag

The data used on this page

Building footprints with LiDAR heights from the 3D BAG (the Dutch building register with heights from AHN), fetched from its WFS for central Den Haag — 4.285–4.330° E, 52.065–52.095° N — exactly as in How to download building footprints from OSM and Overture, and saved as denhaag_bag.parquet in the Dutch national grid (EPSG:28992). The OpenStreetMap answers are read as the Overpass mirror gave them on 10 October 2026.

import io
import os
import zipfile

import geopandas as gpd
import requests
from pyproj import Transformer

# OpenStreetMap as Overpass returned it on 10 October 2026, saved as osmnx's own cache so the counts
# below do not drift as OSM is edited; delete the cache folder to query live instead
if not os.path.exists("cache"):
    reply = requests.get("https://data.spatialworkflow.io/osm-cache-urban-morphology-explained-2026-10-10.zip", timeout=300)
    reply.raise_for_status()
    with zipfile.ZipFile(io.BytesIO(reply.content)) as archive:
        archive.extractall("cache")

if not os.path.exists("denhaag_bag.parquet"):
    W, S, E, N = 4.285, 52.065, 4.330, 52.095
    to_rd = Transformer.from_crs(4326, 28992, always_xy=True)
    (x0, y0), (x1, y1) = to_rd.transform(W, S), to_rd.transform(E, N)
    feats, start = [], 0
    while True:
        fc = requests.get("https://data.3dbag.nl/api/BAG3D/wfs", params={
            "service": "WFS", "version": "2.0.0", "request": "GetFeature", "typeNames": "BAG3D:lod12",
            "count": 5000, "startIndex": start, "outputFormat": "application/json",
            "bbox": f"{x0},{y0},{x1},{y1},EPSG:28992"}, timeout=300).json()
        feats += fc["features"]
        if len(fc["features"]) < 5000:
            break
        start += 5000
    bag = gpd.GeoDataFrame.from_features(feats, crs=28992)
    bag["height"] = bag.b3_h_70p - bag.b3_h_maaiveld
    bag[["identificatie", "b3_h_70p", "b3_h_maaiveld", "height", "b3_opp_grond", "geometry"]] \
        .to_parquet("denhaag_bag.parquet")
print(len(gpd.read_parquet("denhaag_bag.parquet")), "buildings")
23929 buildings

Example 1 — measures at each element

import osmnx as ox
ox.settings.http_user_agent = "spatialworkflow-examples/1.0"
ox.settings.overpass_url = "https://maps.mail.ru/osm/tools/overpass/api"   # mirror; overpass-api.de refused
ox.settings.overpass_rate_limit = False
G = ox.graph_from_point((52.0799, 4.3113), dist=1500, network_type="drive")
streets = ox.graph_to_gdfs(ox.convert.to_undirected(G), nodes=False).to_crs(28992)
limit = gpd.GeoSeries([b.union_all().convex_hull], crs=28992)
blocks = momepy.enclosures(streets, limit=limit)
blocks = blocks[(blocks.area > 500) & (momepy.elongation(blocks) > 0.15)]
s = ox.stats.basic_stats(ox.project_graph(G), area=9e6)
print(f"block: median {blocks.area.median():,.0f} m², {len(blocks) / (limit.area.iloc[0] / 1e6):.0f} per km²")
print(f"street: {s['intersection_density_km']:.0f} intersections/km², "
      f"mean segment {s['street_length_avg']:.0f} m")
block: median 10,038 m², 37 per km²
street: 129 intersections/km², mean segment 80 m

Building, plot, block and street measures are independent views of the same place. Together they separate fabrics that any single one would confuse.

Example 2 — redundancy among building measures

m = gpd.GeoDataFrame({
    "log_area": np.log10(b.area), "perimeter": b.length,
    "compactness": momepy.circular_compactness(b), "convexity": momepy.convexity(b),
    "elongation": momepy.elongation(b), "height": b.height}, geometry=b.geometry)
corr = m.drop(columns="geometry").corr(method="spearman")
pairs = [(a, c, corr.loc[a, c]) for i, a in enumerate(corr) for c in corr.columns[i + 1:]
         if abs(corr.loc[a, c]) > 0.7]
for a, c, r in pairs:
    print(f"{a} ~ {c}: ρ = {r:.2f}")
log_area ~ perimeter: ρ = 0.97
compactness ~ elongation: ρ = 0.87

Pairs above 0.7 measure largely the same thing. A morphometric study that keeps both gives their shared dimension double weight.

Example 3 — the same measure, two footprint sources

osm = ox.features_from_bbox((4.285, 52.065, 4.330, 52.095), {"building": True})
osm = osm[osm.geom_type == "Polygon"].to_crs(28992)
osm = osm[osm.area > 10]
print(f"median footprint: 3D BAG {b.area.median():.0f} m², OSM {osm.area.median():.0f} m²")
print(f"buildings: 3D BAG {len(b):,}, OSM {len(osm):,}")
median footprint: 3D BAG 86 m², OSM 86 m²
buildings: 3D BAG 19,596, OSM 19,800

In Den Haag the two sources agree to the square metre, because Dutch OSM buildings were imported from the same register. That agreement is local luck, not a property of OSM: where buildings were traced by hand, terraces are often drawn as one polygon and the per-building statistics move with that. That is why the source belongs in every morphometric result.

Core morphometric measures grouped by element, with what each captures. a small set that covers the main dimensionselementmeasurecapturesDen Haag centrebuildingfootprint areasizemedian 86 m²buildingheightvertical extentmedian 10.2 mbuildingshared-wall shareattachmentmedian 34%plotcoverage per cellhow built-upmedian 43%blockareagrainmedian 10,038 m²streetintersection densityconnectivity129 per km²Drop one of any pair with ρ above 0.7: area with perimeter, compactness with elongation spatialworkflow.io
A small set that covers the main dimensions of form.

Nested elements and redundant measures

Why nested elements

Urban form is hierarchical: buildings sit on plots, plots make blocks, blocks are separated by streets. Each level changes at a different speed — buildings in decades, plots in centuries, streets rarely — and each carries different information. Measuring only buildings misses why two districts of similar houses feel different.

Why so many measures are redundant

Most shape indices compare a polygon with an ideal form; most size measures are powers of the same length. Correlation among them is expected. The informative contrasts come from different elements and from relations between neighbours, not from more indices of one polygon.

Why data definitions matter so much

Morphometrics amplifies differences in how the data was made. A terrace drawn as one polygon or as twelve changes counts, sizes and shape indices; a street network with or without footpaths changes block sizes. The measures are exact; the objects they measure are conventions.

What a morphometric result has to report alongside its numbers. report these with every measurethe footprint source and datea building is what the source says it isthe unit of landcell, enclosure, grid square or districtthe elementbuilding, plot, block or streetparameterstessellation limit, floor height, sliver filtera single citywide figurehides the spread that is the fabricIn Den Haag OSM and the BAG agree to the m² — elsewhere the source moves the answer spatialworkflow.io
Every measure depends on the source, the unit and the parameters: they belong in the result.

Heights, plots, change over time and scale

  • Heights turn 2D measures into 3D ones (volume, floor area, façade area); they need a 3D source.
  • Plots from cadastres are subdivided for legal reasons that may not match form; tessellation is a consistent alternative.
  • Temporal change: comparing footprints across years needs the same source and the same division rules.
  • Scale: neighbourhood-level measures depend on how many neighbours are included.
  • momepy implements most published measures; its documentation cites the source of each.

FAQ

What is urban morphology?

The study of the physical form of cities — buildings, plots, blocks and streets — and how they combine.

What is momepy?

A Python library that implements urban morphometrics: shape, dimension, intensity, connectivity and diversity measures for buildings, tessellations and street networks.

Which measures matter most?

Size, height, coverage, adjacency between buildings, block size and street connectivity cover the main dimensions.

Do I need cadastral plots?

No. A morphological tessellation is a consistent substitute, especially when bounded by streets.

Why do my results change with the data source?

Sources define buildings differently. Per-building measures inherit the definition.

How do I compare the form of different cities?

Same sources, same units of land, same measures, and neighbourhood context rather than single buildings.