# 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.

- Author: [Ivan Chernoknizhnikov](https://treamz.me/), Flutter & iOS Engineer
- Published: 2026-11-09
- Updated: 2026-11-10
- URL: https://treamz.me/blog/free-parking-map-from-open-data
- Tags: 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](https://apps.apple.com/us/app/easy-find-parking-europe/id6502238318);
the user's side is in [a separate post](https://treamz.me/blog/free-parking-app-europe).

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](https://wiki.openstreetmap.org/wiki/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](https://opendata-ajuntament.barcelona.cat/data/dataset/trams-aparcament-superficie)
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.

```sql
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.

```swift
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:

```swift
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:

```json
{
  "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](https://treamz.me/blog/free-parking-app-europe/free-kerbs-valencia.jpg "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](https://github.com/ParkenDD/park-api-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:

```python
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](https://treamz.me/blog/free-parking-app-europe).

## 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.
