treamz

Mapping free street parking from open data with PostGIS and MapKit

How I turn cities' paid-parking data into likely-free kerbs with PostGIS, why OSM parking tags fail outside Germany, and how SwiftUI's Map draws them.

By 8 min readupdated

  • iOS
  • PostGIS
  • OpenStreetMap
  • Open Data
  • MapKit

This post covers why OpenStreetMap gives kerb-level parking in only some cities, how I turn a council's paid streets into "likely free" kerbs with PostGIS, how that guess stays apart from real facts, and how SwiftUI's Map draws a city's kerbs without stuttering. The app is Easy Find Parking, a free iPhone and iPad map of free street parking in Berlin, Madrid, Barcelona, València and Kraków, on the App Store; the user's side is in a separate post.

Server: Flask, PostGIS and a self-hosted ParkAPI. Phone: SwiftUI on iOS 17.

OpenStreetMap street parking is mostly a German dataset

My first plan was OpenStreetMap everywhere. Kerbside parking is tagged on the street itself, with keys like parking:left=lane and parking:right:fee=no (see Street parking on the OSM wiki). When I measured in September 2026, this was the share of drivable streets carrying any parking:* tag:

City Streets with parking:* tags
Berlin 99.9%
Hamburg 54%
Warsaw 26%
Lyon 22%
Milan 11%
Paris 10%
València 7%
Barcelona 4%
Lisbon 0.9%

Fee sub-tags were on about a tenth of those. Berlin works: 64,229 kerb sides and 48,567 car parks from one Geofabrik extract. Elsewhere a tag-driven layer would be mostly blank, which reads as "no parking here". So outside Germany the street layer comes from municipal open data, which the city actually enforces.

Reading the tags has traps. The wiki deprecated the older parking:lane=* and parking:condition=* keys in December 2022 and asks for existing ones to be converted, so the normaliser reads both. parking:left=no is a value, not an absence: 43% of Berlin's tagged sides. And without closed_ways_are_polygons in its config, GDAL put closed amenity=parking ways in the lines layer, where my highway filter dropped them: Berlin had 42 car-park polygons instead of 63,294.

Deriving "likely free" from what a city charges for

Councils publish what they charge for, because that's what they enforce. Barcelona's surface parking dataset lists its blue, green and goods-delivery segments; Kraków publishes 23 paid-zone polygons and no kerbs. The complement is the free map: every drivable street inside the city boundary with nothing regulated near it.

CREATE TEMP TABLE regulated AS
SELECT geom FROM efp.street_parking
WHERE city_id = :'city' AND fee_source IN ('municipal_paid', 'municipal_free')
UNION ALL
SELECT geom::geography FROM efp.paid_zone WHERE city_id = :'city';  -- Kraków's polygons

-- ...
FROM osm_stage.streets_REGIONTAG s
CROSS JOIN (VALUES ('left'), ('right')) AS sides(side)
WHERE EXISTS (SELECT 1 FROM city_boundary b WHERE ST_Within(s.geom, b.geom))
  AND NOT EXISTS (
      SELECT 1 FROM regulated r
      WHERE ST_DWithin(efp.kerb_offset(s.geom, sides.side)::geography, r.geom, :clearance))
  AND s.highway IN ('residential', 'living_street', 'unclassified', 'tertiary', 'tertiary_link')
  AND lower(coalesce(s.access, '')) NOT IN ('private', 'no', 'customers')

Three of these are there because a looser version published something wrong.

Metres, not degrees. The first version buffered by 0.00025°. At Barcelona's latitude that's 27.8 m north–south but 20.8 m east–west, and 407 streets 21–25 m from a paid kerb went out as free. Casting to geography makes ST_DWithin measure in metres; :clearance is 25.

Test the kerb, not the centreline. efp.kerb_offset shifts the street 3 m to one side, working in EPSG:3857 and dividing the distance by cos(latitude) to undo Mercator's stretch. That line can sit closer to a paid kerb than the centreline, so it's the one tested.

Leave out roads with no parking. The first run turned 17,275 regulated segments into 10,842 free kerb sides, and the map showed dashed "likely free" lines down Avinguda Diagonal. Arterials have no regulated segments because nobody parks on them, so "outside every paid zone" came out true. Limiting the rule to residential, unclassified and tertiary roads dropped 3,156 sides, 29% of the layer. Service roads stay out: they're car-park aisles and driveways as often as public kerb.

Kraków's polygons feed the same rule: 21,255 free kerb sides, none within 25 m of a zone. A test samples 2,000 derived kerbs per city to assert it. Checking every row took over 150 seconds at 29,000 derived against 105,000 regulated, and a check nobody waits for is a check nobody runs.

Free and likely free are different values

The schema never stores a bare "free". Every row has a fee_state (FREE, PAID, CONDITIONAL, UNKNOWN) and a fee_source saying where it came from: municipal_free, osm_tag, operator_feed, municipal_paid_complement and municipal_outside_zone.

The last one came from Madrid, whose SER map service publishes 87,614 kerb bands. 53,158 are grey, a colour missing from the service's own legend. Sampling 300 bands of each colour against the SER boundary settled it: every blue and green band tested fell inside, no grey one did. So grey bands are published kerbs outside the paid zone: stronger than an inference from nothing, weaker than the council saying "free". I first stored them as municipal_paid_complement, and the overlap test failed, correctly: grey bands sit next to blue ones on the same street, which for a derived street would be a bug.

One function turns the pair into a claim, ordered by trust: a current count, a published fact, a schedule, an inference, an admitted gap.

switch (feeState, feeSource) {
case (.free, .municipalFree), (.free, .operatorFeed):
    return .cityFree
case (.free, .osmTag):
    return .signpostedFree
case (.free, .municipalOutsideZone), (.free, .municipalPaidComplement):
    // This is the inference, and it must never borrow a stronger phrasing.
    return .likelyFree
case (.free, _):
    // A free claim with no provenance is not one we will repeat.
    return .unknown
default:
    break
}

Charging hours the parser can't read resolve to "charged right now": wrong that way costs a walk, the other way a fine.

On the first build the inferred layer was mint and read as the same free as the confirmed green. Now it's teal and dashed, which says "not solid" before anyone reads the legend:

ForEach(model.streets) { street in
    MapPolyline(coordinates: street.coordinates)
        .stroke(street.claim.tint.opacity(0.85),
                style: street.claim.isInferred ? .inferredKerb : .kerb)
}

private extension StrokeStyle {
    static let kerb = StrokeStyle(lineWidth: 4, lineCap: .round, lineJoin: .round)
    /// Dashed: derived from an absence of regulation, not from a sign.
    static let inferredKerb = StrokeStyle(
        lineWidth: 4, lineCap: .butt, lineJoin: .round, dash: [3, 7])
}

Both hedged kinds draw the same dashed teal; the data keeps them apart.

Drawing kerbs with SwiftUI's Map

That ForEach is the whole renderer, and its cost is the count: a city-wide Barcelona viewport holds around 660 kerbs. The obvious fix is one MKMultiPolyline per style, about four overlays, but MapPolyline has no initialiser that takes one. So I rewrote the map as MKMapView in a UIViewRepresentable. It worked in the simulator and drew no tiles on a real device. I couldn't reproduce it, and a blank map is worse than a slow one, so I went back to SwiftUI's Map. What keeps it smooth:

  • Measure first. The database answered in 7.6 ms and the endpoint in under half a second, so the stutter was on the phone.
  • Fetch a ring, draw the screen. The app fetches 30% of the span beyond each edge, skips the request while the viewport stays inside it, and waits 250 ms for the camera to settle. Drawing is clipped to the viewport, about 60% fewer lines, and the array is reassigned only when visible IDs change: an identical array still makes SwiftUI diff every overlay.
  • Slim payload. Street name, zone, capacity and source were 65% of the bytes and none is drawn, so the layer gets four fields per kerb and coordinates rounded to six decimals, about 10 cm. A city-wide response went from 575 KB to 359 KB.
  • Nearest first. The query orders by sp.geom <-> centre with a limit of 1,500, so truncation eats the ring, not the middle of the screen.

One feature from the live API:

{
  "type": "Feature",
  "geometry": {
    "type": "LineString",
    "coordinates": [[2.174812, 41.403032], [2.174847, 41.403005], [2.174876, 41.402983]]
  },
  "properties": {
    "source_id": "complement-es-barcelona-885702924-left",
    "fee_state": "FREE",
    "fee_source": "municipal_paid_complement",
    "opening_hours": null
  }
}

A cap of 400 drawn kerbs left the edges of a Barcelona screen bare, and a driver can't tell a capped edge from a street with no parking, so it went.

València publishes individual spaces, 315,303 averaging 2.6 m, so 1,500 features covered a few blocks. ST_ClusterDBSCAN, partitioned by street, fee state and a grid of about 300 m, now merges adjacent bays into segments with a bay count: the same view went from 1,500 features and 453 KB to 582 and 135 KB. Without the grid, nameless paid bays chained into lines 25 km long. A polyline threaded through the bays zig-zagged across the street, since bays sit on both sides, so each cluster is drawn as the longest line across its convex hull.

The map in València at street level, with free kerbs drawn as solid green lines
València: each green line is a run of free bays merged into one segment

Zoomed out past 0.02° of span, the kerb layer switches off and the gateway counts kerbs in roughly an 8×8 grid over the viewport, drawn as circles. These aren't MapKit annotation clusters: the phone never downloads the kerbs at that zoom. Circle size is logarithmic, because counts span four orders of magnitude and linear sizing pinned nearly every circle to the maximum.

Live counts go stale after 15 minutes

The old app read api.parkendd.de, which had stopped updating. Now I run my own ParkAPI v3 (MIT), pulling realtime data every 300 seconds into the same Postgres as my schema, so the gateway joins its parking_site table directly. The gateway decides freshness, so phone and server can't disagree:

STALE_AFTER = timedelta(minutes=15)

def freshness(updated_at: datetime | None) -> str:
    if updated_at is None:
        return 'none'
    age = datetime.now(timezone.utc) - updated_at
    return 'live' if age <= STALE_AFTER else 'stale'

for row in rows:
    row['freshness'] = freshness(row.get('realtime_updated_at'))
    # Keep the number, drop the claim that it is current.
    row['realtime_is_current'] = row['freshness'] == 'live'

ParkAPI's own public API drops realtime fields once they're too old. I keep the number and label it: a stale count shows grey as "N free when last seen", with its age. On 9 November 2026, 15 car parks within 2 km of central Basel had a current count.

Indigo's feed reported zero occupancy for all 558 French car parks, which naive arithmetic shows as empty, so realtime is off for it. And 28% of upstream ParkAPI converters were already dead when I set it up, so each source's last success and error live in a table.

Licences and gaps

OpenStreetMap data is ODbL (© OpenStreetMap contributors). Barcelona and València publish under CC BY 4.0, Madrid and Kraków under their own open-data terms, the French national base and Indigo under ODbL. Derived layers credit both the council and OSM, and the app shows these credits behind its info button.

Missing: an offline cache, address search, and Warsaw. Its city API timed out when I checked, and OSM had fee information on 0% of its central streets. Kerb lines show rules, not empty spaces.

What the app does for the people who use it: Free parking app for Europe: Easy Find Parking for iPhone.

FAQ

How complete is OpenStreetMap parking data in European cities?

Very uneven. When I measured in September 2026, the share of drivable streets with any parking:* tag was 99.9% in Berlin, 10% in Paris, 4% in Barcelona and 0.9% in Lisbon, and about a tenth of tagged streets had fee information. Outside Germany, municipal open data is usually the better source.

How do you find free street parking from a city's paid parking data?

Take the OpenStreetMap road network inside the city boundary and drop any street side within a clearance (25 m in Easy Find Parking) of a published paid segment or zone polygon. Limit it to residential, unclassified and tertiary roads, or arterials come out free, and label the result "likely free".

Should I buffer in degrees or metres in PostGIS?

Metres. On EPSG:4326 geometry, distances are in degrees, and a degree of longitude shrinks with latitude: 0.00025° is 27.8 m north–south but 20.8 m east–west in Barcelona. Cast to geography and ST_DWithin takes metres.

How do I draw thousands of polylines in a SwiftUI Map without lag?

SwiftUI's Map needs one MapPolyline per line and can't take an MKMultiPolyline, so the line count is the cost. Fetch a padded area, skip requests while the viewport stays inside it, debounce camera changes by about 250 ms, draw only what's visible and send only the fields you draw. Zoomed out, show area counts instead.

How do you know if a live car park count is still current?

Compare its timestamp with the server's clock. Easy Find Parking treats a count older than 15 minutes as stale: it keeps the number but shows it as "free when last seen" with its age, never as spaces available now.

More writing