How to compute building shadows for a given time in Python

On this page

Problem statement

A shadow is a projection: take the building's outline, push it away from the sun by a distance that depends on the sun's elevation, and union the result with the building. The geometry is straightforward. The two things that make shadow analysis wrong are the sun position and the azimuth convention, and neither raises an error.

The scale of the effect is worth stating before any code. At The Hague, a 10 m building casts a 7.0 m shadow at midsummer noon and a 40.1 m shadow at midwinter noon — a factor of 5.7. Any analysis that picks "a sunny day" without specifying the date is not an analysis.

This guide computes shadows for a real footprint layer at real sun positions, and gets the conventions right.

Quick answer

import numpy as np, pandas as pd, pvlib, geopandas as gpd
from shapely.affinity import translate
from shapely.ops import unary_union

def shadow(geom, height, elevation_deg, azimuth_deg):
    """Azimuth is degrees clockwise from north; the shadow points away from the sun."""
    if elevation_deg <= 0:
        return None
    length = height / np.tan(np.radians(elevation_deg))
    dx = -length * np.sin(np.radians(azimuth_deg))
    dy = -length * np.cos(np.radians(azimuth_deg))
    moved = translate(geom, dx, dy)
    return unary_union([geom, moved, geom.union(moved).convex_hull])

sp = pvlib.solarposition.get_solarposition(
    pd.DatetimeIndex(["2026-12-21 12:00"]).tz_localize("Europe/Amsterdam"), 52.104, 4.2726)
elev, azim = sp.apparent_elevation.iloc[0], sp.azimuth.iloc[0]
shadows = [shadow(g, h, elev, azim) for g, h in zip(b.geometry, b.height_m)]

pvlib gives apparent elevation — corrected for atmospheric refraction — which is what you want, because refraction lifts the apparent sun by about half a degree near the horizon and that changes a long shadow noticeably.

A building, the sun elevation and azimuth, and the resulting shadow polygon. the geometrysunshadowL = h / tan(elevation)in plansun at az 180°shadow northoffset (−L sin az, −L cos az)The shadow points away from the sun — the sign everyone gets wrong spatialworkflow.io
Two angles and a height; the sign of the offset is what everyone gets wrong.

What you get — from The data used on this page

1,990 footprints, heights 0.5-25.1 m (median 6.7); extent 678 x 616 m

Library sun positions, aware timestamps, azimuth conventions

1. Get the sun position from a library, not a formula

Solar position depends on the date, the time, the time zone, the latitude, the longitude and the equation of time. pvlib.solarposition.get_solarposition handles all of it and returns both true and apparent elevation.

2. Use a timezone-aware timestamp

A naive timestamp is interpreted as UTC by most libraries, which on a summer afternoon in western Europe is two hours out. At 15:00 on 21 June in The Hague that moved the sun from azimuth 213.9° to 251.4° and from 57.9° to 43.3° elevation (Example 4).

3. Get the azimuth convention right

pvlib returns azimuth in degrees clockwise from north: 90° is east, 180° is south, 270° is west. The shadow points away from the sun, so the offset is (-L·sin(az), -L·cos(az)). Getting the sign wrong puts every shadow on the sunny side, which looks entirely plausible on a map.

4. Skip times when the sun is below the horizon

elevation <= 0 means no shadow, not an infinite one. At 08:00 on the winter solstice in The Hague the apparent elevation is −7.07°, and at 09:00 it is 0.86° — which already gives a 1,679 m shadow from the tallest building in the tile.

5. Build the shadow as a swept polygon

Translating the footprint gives the far end; the union of the original, the translation and the convex hull of the two gives the swept region. For a convex footprint that is exact; for a concave one the convex hull slightly overstates the shadow, and the alternative is to sweep each edge.

6. Union the shadows to get shaded ground

Overlapping shadows must be unioned before any area is computed, or the total double-counts.

7. Subtract the buildings if you want shaded open ground

A building's own footprint is not shaded ground; it is a building.

8. Accumulate over a day for a duration map

Rasterise each timestep's shaded area onto a grid and sum, which gives hours of shade per cell — far more useful than one instant.

Shadow length from a 10 metre building at 12:00 local time on three dates. 10 m building at 12:00, The Hague21 June, 55.06°7.0 m21 March, 37.19°13.2 m21 December, 13.99°40.1 m045 mA factor of 5.7 between midsummer and midwinter at 52°N spatialworkflow.io
The same building, the same hour: a factor of 5.7 between midsummer and midwinter.

Shaded area and hours of shade through a winter day

The data used on this page

The LoD2 tile of The Hague's 3D city model published as a CityJSON sample dataset (DenHaag_01.city.json), in the Dutch national grid with NAP heights. The setup takes each building's ground surface as its footprint and its recorded ridge height above that ground as height_m, as a GeoDataFrame b in EPSG:28992.

import json
import os

import geopandas as gpd
import numpy as np
import requests
from shapely.geometry import Polygon

if not os.path.exists("model.city.json"):
    reply = requests.get("https://3d.bk.tudelft.nl/opendata/cityjson/3dcities/v2.0/DenHaag_01.city.json", timeout=300)
    reply.raise_for_status()
    open("model.city.json", "wb").write(reply.content)
d = json.load(open("model.city.json"))
V = np.array(d["vertices"]) * d["transform"]["scale"] + d["transform"]["translate"]

rows = []
for oid, o in d["CityObjects"].items():
    attrs = o.get("attributes") or {}
    for g in o.get("geometry", []):
        if g["type"] != "Solid" or not g.get("semantics") or "AbsoluteRidgeHeight" not in attrs:
            continue
        surfaces = g["semantics"]["surfaces"]
        for face, si in zip(g["boundaries"][0], g["semantics"]["values"][0]):
            if si is not None and surfaces[si].get("type") == "GroundSurface":
                ground = float(np.mean([V[i][2] for i in face[0]]))
                rows.append({"id": oid, "height_m": attrs["AbsoluteRidgeHeight"] - ground,
                             "geometry": Polygon([V[i][:2] for i in face[0]])})
b = gpd.GeoDataFrame(rows, crs=28992)
b = b[b.is_valid & (b.height_m > 0)].reset_index(drop=True)
minx, miny, maxx, maxy = b.total_bounds
print(f"{len(b):,} footprints, heights {b.height_m.min():.1f}-{b.height_m.max():.1f} m (median {b.height_m.median():.1f}); "
      f"extent {maxx - minx:.0f} x {maxy - miny:.0f} m")
1,990 footprints, heights 0.5-25.1 m (median 6.7); extent 678 x 616 m

Example 1 — sun positions through a winter day

import pandas as pd, pvlib

lat, lon = 52.104, 4.2726
times = pd.date_range("2026-12-21 08:00", "2026-12-21 16:00", freq="1h",
                      tz="Europe/Amsterdam")
sp = pvlib.solarposition.get_solarposition(times, lat, lon)
print(sp[["apparent_elevation", "azimuth"]].round(2).to_string())
                           apparent_elevation  azimuth
2026-12-21 08:00:00+01:00               -7.07   119.54
2026-12-21 09:00:00+01:00                0.86   131.09
2026-12-21 10:00:00+01:00                6.81   143.37
2026-12-21 11:00:00+01:00               11.38   156.49
2026-12-21 12:00:00+01:00               13.99   170.33
2026-12-21 13:00:00+01:00               14.41   184.50
2026-12-21 14:00:00+01:00               12.58   198.52
2026-12-21 15:00:00+01:00                8.68   211.93
2026-12-21 16:00:00+01:00                3.11   224.51

Note that the maximum elevation is at 13:00 local time, not 12:00 — solar noon is displaced by the longitude offset within the time zone and by the equation of time.

Example 2 — shaded area through the day

import numpy as np
from shapely.ops import unary_union

results = []
for ts, row in sp.iterrows():
    if row.apparent_elevation <= 0:
        continue
    polys = [shadow(g, h, row.apparent_elevation, row.azimuth)
             for g, h in zip(b.geometry, b.height_m)]
    shade = unary_union([p for p in polys if p is not None])
    results.append({"time": ts.strftime("%H:%M"),
                    "elevation": round(row.apparent_elevation, 2),
                    "azimuth": round(row.azimuth, 2),
                    "shadow_m2": round(shade.area),
                    "max_length_m": round(b.height_m.max() / np.tan(np.radians(row.apparent_elevation)), 1)})
print(pd.DataFrame(results).to_string(index=False))
 time  elevation  azimuth  shadow_m2  max_length_m
09:00       0.86   131.09     581672        1678.9
10:00       6.81   143.37     230305         209.9
11:00      11.38   156.49     201021         124.6
12:00      13.99   170.33     193688         100.6
13:00      14.41   184.50     199085          97.6
14:00      12.58   198.52     212335         112.4
15:00       8.68   211.93     239181         164.2
16:00       3.11   224.51     339680         460.8

The footprints span 678 m by 616 m, 0.42 km², and at noon 45.4% of that is either building or shadow (Example 4). At 09:00 the shadow area is 0.58 km², more than the whole tile, because shadows run far beyond it.

Example 3 — hours of shade per square metre

import numpy as np, rasterio
from rasterio.features import rasterize
from rasterio.transform import from_origin

def shade_hours(buildings, sun_positions, res=2.0):
    minx, miny, maxx, maxy = buildings.total_bounds
    w, h = int((maxx - minx) / res), int((maxy - miny) / res)
    tr = from_origin(minx, maxy, res, res)
    hours = np.zeros((h, w), dtype="float32")

    steps = [t for t, r in sun_positions.iterrows() if r.apparent_elevation > 0]
    dt = 1.0 if len(steps) < 2 else (steps[1] - steps[0]).total_seconds() / 3600

    for ts in steps:
        r = sun_positions.loc[ts]
        polys = [shadow(g, ht, r.apparent_elevation, r.azimuth)
                 for g, ht in zip(buildings.geometry, buildings.height_m)]
        polys = [p for p in polys if p is not None]
        if not polys:
            continue
        mask = rasterize(polys, out_shape=(h, w), transform=tr, fill=0, default_value=1,
                         dtype="uint8")
        hours += mask * dt
    return hours, tr

hours, tr = shade_hours(b, sp)
print(f"median shade hours: {np.median(hours):.1f}, max {hours.max():.1f}")
median shade hours: 4.0, max 8.0

Example 4 — the numbers quoted on this page

from shapely.geometry import box

site = dict(latitude=52.104, longitude=4.2726)
noon = pd.DatetimeIndex(["2026-06-21 12:00", "2026-03-21 12:00", "2026-12-21 12:00"]).tz_localize("Europe/Amsterdam")
for ts, e in pvlib.solarposition.get_solarposition(noon, **site).apparent_elevation.items():
    print(f"{ts:%d %b} 12:00  elevation {e:5.2f}°  shadow of a 10 m building {10 / np.tan(np.radians(e)):5.1f} m")

# A naive timestamp is read as UTC: 15:00 on 21 June, local against naive
aware = pvlib.solarposition.get_solarposition(pd.DatetimeIndex(["2026-06-21 15:00"]).tz_localize("Europe/Amsterdam"), **site)
naive = pvlib.solarposition.get_solarposition(pd.DatetimeIndex(["2026-06-21 15:00"]), **site)
print(f"15:00 local: azimuth {aware.azimuth.iloc[0]:.1f}°, elevation {aware.apparent_elevation.iloc[0]:.1f}°;  "
      f"naive (UTC): azimuth {naive.azimuth.iloc[0]:.1f}°, elevation {naive.apparent_elevation.iloc[0]:.1f}°")

# Refraction: pvlib's SPA correction at standard pressure and 12 °C
for true_el in (0.5, 5.0, 14.0):
    apparent_el = true_el + pvlib.spa.atmospheric_refraction_correction(1013.25, 12, true_el, 0.5667)
    print(f"true {true_el:4.1f}° -> apparent {apparent_el:5.2f}°: 10 m casts {10 / np.tan(np.radians(true_el)):7.1f} m "
          f"at true, {10 / np.tan(np.radians(apparent_el)):7.1f} m at apparent")

tile = box(*b.total_bounds)
for ts in ("12:00", "13:00"):
    row = sp.loc[f"2026-12-21 {ts}"]
    shade = unary_union([p for p in (shadow(g, h, row.apparent_elevation, row.azimuth)
                                     for g, h in zip(b.geometry, b.height_m)) if p is not None])
    print(f"{ts}: {shade.intersection(tile).area / tile.area:.1%} of the {tile.area / 1e6:.2f} km² tile shaded or built, "
          f"{shade.difference(tile).area:,.0f} m² of shadow outside it")
21 Jun 12:00  elevation 55.06°  shadow of a 10 m building   7.0 m
21 Mar 12:00  elevation 37.19°  shadow of a 10 m building  13.2 m
21 Dec 12:00  elevation 13.99°  shadow of a 10 m building  40.1 m
15:00 local: azimuth 213.9°, elevation 57.9°;  naive (UTC): azimuth 251.4°, elevation 43.3°
true  0.5° -> apparent  0.92°: 10 m casts  1145.9 m at true,   626.0 m at apparent
true  5.0° -> apparent  5.16°: 10 m casts   114.3 m at true,   110.7 m at apparent
true 14.0° -> apparent 14.07°: 10 m casts    40.1 m at true,    39.9 m at apparent
12:00: 45.4% of the 0.42 km² tile shaded or built, 4,348 m² of shadow outside it
13:00: 46.8% of the 0.42 km² tile shaded or built, 3,894 m² of shadow outside it

Refraction and the azimuth sign

Why apparent elevation rather than true

Atmospheric refraction bends light, lifting the apparent position of the sun near the horizon far more than higher up: in pvlib's model, by 0.42° at a true elevation of 0.5° and by 0.07° at 14°. At low sun that difference is large in shadow terms: at a true 0.5° the apparent elevation is 0.92°, and the shadow of a 10 m building shortens from 1,146 m to 626 m; at 14° it is 40.1 m against 39.9 m (Example 4). Use apparent elevation; it is what an observer sees.

Why the azimuth sign is the classic bug

Azimuth in pvlib is clockwise from north, and the shadow points in the opposite direction. The offset (-L·sin(az), -L·cos(az)) encodes that. Using + gives shadows on the sunny side, which on a map looks like a rendering choice rather than an error, and survives review depressingly often.

Why shadow length is so sensitive near sunrise and sunset

Length is h / tan(elevation), which goes to infinity as elevation goes to zero. At 09:00 on the solstice the elevation is 0.86° and the tallest building in the tile casts 1,679 m; an hour later at 6.81° it casts 210 m. Any analysis that averages across the day has to decide what to do with the first and last hour, because they dominate every total.

Why hours-of-shade beats a single instant

A shadow at one moment tells you very little; a garden shaded at 09:00 and sunlit from 10:00 is a sunny garden. Accumulating a mask over the day, weighted by the timestep, gives hours of direct sun per cell, which is the quantity planning policy and daylight guidance actually use.

Accumulating hours of shade rather than a single instant. from one instant to hours of shade1sun positions through the dayskip elevation ≤ 02shadow polygons per timestepunion them3rasterise each uniona mask per step4sum, weighted by the timestephours per cellA garden shaded at 09:00 and sunlit from 10:00 is a sunny garden spatialworkflow.io
Daylight guidance is written in hours, not in instants.

Flat ground, terrain and trees

  • Flat ground is assumed. On a slope, the shadow runs along the terrain and needs a raster method.
  • Terrain casts shadows too. A hill to the south shades everything behind it.
  • Trees are not buildings and are often the dominant shade in residential streets.
  • Convex hull overstates concave footprints slightly; sweep edges if it matters.
  • Use a fine timestep near sunrise and sunset, where the length changes fastest.
  • Time zones and DST. Always localise the timestamps.
  • Check the CRS is projected. Metres in, metres out.
  • The shadow of a pitched roof is not the shadow of a prism to the ridge.

FAQ

How do I calculate a building's shadow in Python?

Translate the footprint by height / tan(elevation) in the direction away from the sun, and union it with the original and the convex hull of the two.

Where do I get the sun's position?

pvlib.solarposition.get_solarposition(times, lat, lon) with timezone-aware timestamps. Use apparent_elevation, which includes refraction.

Which way does the shadow point?

Away from the sun. With azimuth measured clockwise from north, the offset is (-L·sin(az), -L·cos(az)).

How much does the date matter?

Enormously. A 10 m building at The Hague casts 7.0 m at midsummer noon and 40.1 m at midwinter noon.

What do I do about sunrise and sunset?

Skip elevations at or below zero, and use a fine timestep near the horizon — at 0.86° elevation a building in the test cast a 1,679 m shadow.

Is a single-instant shadow map useful?

Rarely. Accumulate a shaded mask over the day to get hours of shade per cell, which is what daylight guidance is written in terms of.