Building Density Measures Explained
On this page
Problem statement
"Dense" is used for the medieval core of a European city, a Hong Kong housing estate and a Manhattan office district, and each is dense in a different way. The first covers most of its ground with low buildings; the second stacks tall towers on a small share of its ground; the third does both. A single density figure β homes per hectare, people per kmΒ², floor area ratio β cannot tell them apart.
Urban designers settled this decades ago with a small set of linked measures, known after the Dutch work that popularised them as Spacemate: how much ground is covered, how much floor is built, and the two ratios that follow. Together they place any area on a map of form, not just of quantity.
Quick answer
GSI = building footprint / land area ground coverage, 0β1
FSI = gross floor area / land area floor area ratio, 0 to 10+
L = FSI / GSI average storeys
OSR = (1 β GSI) / FSI open space per mΒ² of floor
import numpy as np, geopandas as gpd
from shapely.geometry import box
b = gpd.read_parquet("denhaag_bag.parquet")
b = b[b.is_valid & (b.area > 10)].reset_index(drop=True)
b["floors"] = np.maximum(1, np.round((b.b3_h_70p - b.b3_h_maaiveld).clip(lower=2.5) / 3.2))
b["gfa"] = b.area * b.floors
x0, y0, x1, y1 = b.total_bounds
grid = gpd.GeoDataFrame(geometry=[box(x, y, x + 250, y + 250)
for x in np.arange(x0, x1 - 250, 250)
for y in np.arange(y0, y1 - 250, 250)], crs=b.crs)
pts = gpd.GeoDataFrame(b[["gfa"]].assign(fp=b.area), geometry=b.representative_point(), crs=b.crs)
agg = gpd.sjoin(pts, grid, predicate="within").groupby("index_right")[["fp", "gfa"]].sum()
agg["GSI"] = agg.fp / 62_500
agg["FSI"] = agg.gfa / 62_500
agg["L"] = agg.FSI / agg.GSI
agg["OSR"] = (1 - agg.GSI) / agg.FSI
print(f"{len(agg)} squares of 250 m with buildings")
print(agg[["GSI", "FSI", "L", "OSR"]].describe(percentiles=[.1, .5, .9]).loc[
["10%", "50%", "90%"]].round(2).to_string())
174 squares of 250 m with buildings
GSI FSI L OSR
10% 0.08 0.34 3.03 0.23
50% 0.32 1.28 3.98 0.52
90% 0.47 2.50 7.08 2.65
Across central Den Haag's 250 m squares, coverage and floor area vary several-fold, and so does the average height β which is exactly the variation a single density number hides.
Coverage and floor area separately, then height and open space
1. Measure coverage and floor area separately
GSI needs footprints; FSI needs footprints and storeys. Keep both.
2. Derive height and open space
L (storeys) and OSR (open space per floor area) follow from the two. They are what make a GSIβFSI pair legible to a designer.
3. Fix the land unit
A plot, a block, a grid square or a district. Larger units include more streets and open space, so the same buildings give lower ratios. Compare like with like.
4. Plot GSI against FSI
On a GSIβFSI chart, lines through the origin are constant storeys; curves are constant open space. Types of urban fabric occupy recognisable regions.
5. Add population or dwellings only on top
People and homes per hectare depend on floor area per person, which varies by country and income. Form measures are the base; occupancy is a second layer.
Same floor area, different forms
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).
import os
import geopandas as gpd
import requests
from pyproj import Transformer
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 β same floor area, different forms
target = agg.FSI.median()
similar = agg[(agg.FSI - target).abs() < 0.15].sort_values("L")
lo, hi = similar.iloc[0], similar.iloc[-1]
print(f"two squares with FSI near {target:.2f}:")
for label, r in (("lowest-rise", lo), ("highest-rise", hi)):
print(f" {label:<13} GSI {r.GSI:.2f}, FSI {r.FSI:.2f}, L {r.L:.1f} storeys, OSR {r.OSR:.2f}")
two squares with FSI near 1.28:
lowest-rise GSI 0.45, FSI 1.26, L 2.8 storeys, OSR 0.44
highest-rise GSI 0.17, FSI 1.37, L 8.2 storeys, OSR 0.61
The same amount of floor space, arranged as low buildings covering much of the ground or taller ones covering less. FSI alone reports them as equal.
Example 2 β the unit changes the ratio
whole = {"GSI": b.area.sum() / ((x1 - x0) * (y1 - y0)),
"FSI": b.gfa.sum() / ((x1 - x0) * (y1 - y0))}
print(f"whole extent: GSI {whole['GSI']:.2f}, FSI {whole['FSI']:.2f}")
print(f"median 250 m square: GSI {agg.GSI.median():.2f}, FSI {agg.FSI.median():.2f}")
print(f"only squares > 30% covered: GSI {agg[agg.GSI > .3].GSI.median():.2f}, "
f"FSI {agg[agg.GSI > .3].FSI.median():.2f}")
whole extent: GSI 0.28, FSI 1.38
median 250 m square: GSI 0.32, FSI 1.28
only squares > 30% covered: GSI 0.40, FSI 1.61
Three honest-sounding summaries of the same buildings disagree, and not even in one direction. The median square is more covered than the whole extent (0.32 against 0.28), because the extent includes parks, water and wide streets; but its floor area ratio is lower (1.28 against 1.38), because the towers concentrate floor space in a minority of squares that a median ignores and a total does not. Keeping only the built-up squares raises both.
Example 3 β a rough fabric classification
def fabric(r):
if r.GSI < 0.15:
return "open (low coverage)"
if r.L >= 5:
return "high-rise"
if r.GSI >= 0.40:
return "compact low/mid-rise"
return "mid-rise, mixed"
agg["type"] = agg.apply(fabric, axis=1)
print(agg.type.value_counts().to_string())
type
mid-rise, mixed 70
high-rise 37
compact low/mid-rise 36
open (low coverage) 31
Thresholds like these are conventions, not laws, but they turn two continuous ratios into categories a planner recognises β and make clear that "density" was always at least two numbers.
Two measures are the minimum
Why two measures are the minimum
Any built volume has a footprint and a height; density needs both to be described. GSI captures the first, L the second, and FSI is their product. Reporting FSI alone throws away the split between them, which is precisely what distinguishes a terrace from a tower.
Why OSR is useful
OSR says how much unbuilt land there is per square metre of floor β how crowded the open space is likely to feel. Two areas with the same coverage can have very different OSR if one is much taller: the same courtyard shared by twice as many floors.
Why occupancy is a separate question
Floor area per person ranges from under 20 mΒ² to over 60 mΒ² between countries and incomes. People per hectare mixes form with how the floor is used. Built-form density is stable over decades; occupancy changes with every household.
Floor heights, non-residential floors and parks
- Floor height assumption: FSI from LiDAR heights depends on an assumed floor-to-floor height; report it.
- Non-residential floor counts in FSI; residential FSI needs use data.
- Underground floors are invisible to heights.
- Water and parks in the land unit lower every ratio; decide whether to use gross or net land.
- Other names: FSI is FAR; GSI is building coverage ratio or site coverage.
Internal links
- How to calculate coverage and floor area ratio β the computations in full
- Density depends on the boundary β the land unit problem
- Urban morphology explained β density among other measures
- Morphological tessellation explained β the plot-scale unit
- How to calculate building volumes β heights in 3D
- Counts, rates and densities explained β densities in general
FAQ
What is the difference between FAR and coverage?
Coverage (GSI) is the share of land under buildings; FAR (FSI) is floor area divided by land area, so it includes height.
What is Spacemate?
A framework using GSI, FSI, average storeys and open space ratio together to describe built density.
Can two areas have the same FAR and look different?
Yes β low buildings with high coverage and tall buildings with low coverage can share an FAR.
What is the open space ratio?
Unbuilt land per unit of floor area: (1 β GSI) / FSI.
How do I get floor area?
Footprint area times number of storeys, from a register or estimated from height.
Why do density figures for the same city differ?
Different land units, source data and floor-height assumptions.