# Postal code radius search

How we built a focused postal-code radius microservice instead of adopting a broad GIS SaaS platform.

![A black-and-white photograph of a city. By Simon Infanger](/_astro/EinSWFotoEinerStadt.BZQmryP3.jpg)

## Engineering a focused GIS microservice from scratch

For local advertising campaigns targeted to an advertiser's vicinity, postal codes are often the standard unit for booking. That was the starting point for this project. The requirement was narrow:

Given an address, return the postal codes within a certain radius.

Professional GIS and location-data services can solve this, of course. But in this case, most of the available third-party options were too broad for the actual need. We would have paid for feature sets we did not need, pricing models built around larger use cases, and service-level promises that were not really relevant for this product component.

So the question became:

If we isolate this exact use case, can we build a small, reliable microservice that does only this one thing well?

> Can we build a GIS microservice that does only this one thing well?

## Build only what the product needs.

![Marco Di Sarno pondering](/_astro/marco_di_sarno_pondering.ig4EzAzQ.png)

This post walks through the requirements, the geospatial decisions behind the implementation, the data pipeline, and the performance results.

#### The project

The target was a REST API that accepts an address and a distance, then returns all postal codes within that radius.

The service had to meet a few practical requirements:

- expose a simple API for product integration
- resolve an address into geographic coordinates
- search German postal-code areas by radius
- return a useful result in less than one second
- stay maintainable without introducing unnecessary infrastructure

The problem sounds simple at first. An address becomes a latitude-longitude pair. The distance becomes a radius around that point. The service then needs to identify which postal-code areas fall inside that circle.

The complexity starts with the postal-code areas themselves.

Germany has roughly 9,000 postal-code areas. They are not standardized geometric units. They are historically grown shapes with irregular borders. They lack predictable geometric patterns and are characterized by diverse shapes, including:

- Contiguous
- Split
- Containing Holes

![A simple area with one enclosing border.](/_astro/area_simple.8Wj2hiwe.svg)

![A split area with two separate parts.](/_astro/area_split.Bx1_Dc1F.svg)

![An area with a hole in the middle.](/_astro/area_holed.BC_2BmTL.svg)

That means the main question of the radius search is: When do we consider a postal-code area to be inside the radius?

#### The core decision

This is where the actual shape of the solution started to emerge.

A postal-code area can be considered “within radius” in several ways. We discussed three:

one of its borders is within the radius

its center of mass is within the radius

a certain percentage of its total area is within the radius

Each option sounds reasonable until you look at the consequences.

## one border is within radius

The first option is to include a postal-code area as soon as any part of its border touches the search radius.

This sounds intuitive, but it can produce noisy results. Even a tiny sliver of an area would be enough to include the entire postal code. For some use cases that might be acceptable. For this one, it would make the result too broad.

It also has performance implications. A border is not a single point. It consists of many segments, and each postal-code area can have hundreds of border lines, so this can quickly rack up computation time.

## center of mass is within radius

The second idea is even simpler: use the so-called centroid of each postal-code area. If that point is within the search radius, include the area.

This is very fast because it is only a point-to-point distance check.

But the centroid is just one point. With irregular shapes, it can give false results: an area can reach into the radius while its center stays outside, or the center can be inside while most of the area is not. In some cases, the center point may even lie in a hole or outside the actual shape.

![A false negative search on a simple area.](/_astro/false_negative_simple.Mr6_qNmn.svg)

![A false positive search on a split area.](/_astro/false_positive_split.DDdRcZlB.svg)

![A false positive search on an area with a hole.](/_astro/false_positive_holed.B9JJuYJJ.svg)

So while this approach was attractive, we needed something more reliable.

## a certain percentage of its total area is within radius

The third option is to look at how much of a postal-code area lies inside the radius.

This gives the most control. Instead of asking whether a single point or border segment matches, we can calculate or approximate an intersection ratio. The result becomes more meaningful: not only which postal codes are nearby, but how strongly each area overlaps with the search radius.

A precise geometric intersection between a circle and thousands of complex polygons is possible with geospatial libraries. But exactness has a cost, especially if we want to keep the runtime lightweight and the request latency below one second.

So we chose an approximation.

For each postal-code area, we used its dimensions and centroid to sample points around the area. The implementation uses 3,600 sample points, one every 0.1 degrees. We can then keep the runtime calculation simple: measure the distance from the query point to each sample point and estimate coverage based on how many samples fall inside the radius.

This keeps the service fast while returning a more useful value than a plain yes-or-no match.

The result is an `intersection_ratio`, which describes how much of the postal-code area is estimated to be covered by the radius.

![An ellipse drawn around the centroid of a simple area.](/_astro/area_simple_centroid_and_ellipse.cOY_Fsoo.svg)

![An ellipse drawn around the centroid of a split area.](/_astro/area_split_centroid_and_ellipse.jEjMEYU0.svg)

![An ellipse drawn around the centroid of a holed area.](/_astro/area_holed_centroid_and_ellipse.D_B7oyGx.svg)

#### The data

The service needed current map data for Germany, preferably from an open and reusable source. OpenStreetMap was the natural foundation.

The main data requirement was postal-code geometry: the polygons that describe the boundaries of German postal-code areas. For that we used the [`postleitzahlen` repository by Sebastian Vollnhals](https://github.com/yetzt/postleitzahlen), which derives postal-code boundary polygons from OpenStreetMap data via the Overpass API and returns them as GeoJSON.

For centroid-based lookups and enrichment, we also used a [CSV dataset](https://github.com/WZBSocialScienceCenter/plz_geocoord) from the [Berlin Social Science Center](https://www.wzb.eu/en) containing German postal codes and their centroid coordinates.

The last required piece was address lookup. The API accepts an address, but the radius search itself needs coordinates. So the system also needed parser and lookup logic to translate an address into latitude and longitude.

The relevant inputs were therefore:

- postal-code boundary polygons as GeoJSON
- postal-code centroid coordinates
- derived size information for each postal-code area
- an offline address-to-coordinate lookup database

#### The tech

Once the data sources and formats were clear, the architecture came down to one main principle: prepare as much as possible before runtime.

Germany-wide OpenStreetMap data is several gigabytes. Searching through that directly on every request would be the wrong design. The service should not do heavy data extraction when a product user waits for an API response.

Instead, we split the system into preparation and runtime.

The preparation step runs regularly and produces the artifacts the service needs:

- a GeoJSON file with postal-code border polygons
- enriched postal-code metadata, including area, height, and width
- a database for offline address-to-coordinate lookup

At runtime, the service keeps the postal-code geometry available in memory for fast calculation. Address lookup uses an SQLite database, downloaded from S3-compatible object storage when the service starts.

For larger requirements, a PostGIS database would be a valid option. In this case, it would have added more operational weight than necessary. SQLite, GeoJSON, and in-memory geometry processing were enough for the targeted use case.

The implementation stack:

- Python for data processing and geospatial logic
- FastAPI and Uvicorn for the REST API
- Shapely for geometry handling
- osmium for processing OpenStreetMap data
- SQLite for offline address lookup
- Docker, CI image publishing, and Helm for deployment

This is the advantage of a focused microservice: the architecture can stay proportional to the problem.

#### The result

Eventually, the service evolved into:

- a radius-search core over German postal-code data in [GeoJSON](https://geojson.org/) format
- API endpoints for search and address-based lookup
- an SQLite database for offline address-to-coordinate lookup
- deployment-ready packaging with Docker, CI image publishing, and Helm

A simplified request looks like this:

```bash
curl -sS -X POST http://127.0.0.1:8008/api/areacodes \
-H "Content-Type: application/json" \
-d '{"address": "Mittelweg 50, 12053 Berlin", "radius": 0.5}'
```

For a 500 m radius around our company address, the shortened response looks like this:

![Mittelweg 50, 12053 Berlin with postal code areas in a 500m radius](/_astro/radius_500m.B0HUbwO2.png)

```json
[
  { "areacode": "12053", "name": "Berlin Neukölln", "intersection_ratio": 0.543586 },
  { "areacode": "12049", "name": "Berlin Neukölln", "intersection_ratio": 0.297312 },
  { "areacode": "12043", "name": "Berlin Neukölln", "intersection_ratio": 0.00051 },
  { "areacode": "12051", "name": "Berlin Neukölln", "intersection_ratio": 0.007677 }
]
```

A cross-check with Google Maps mostly checks out. Around Thomashöhe, our OSM-based extract even appears to be more accurate for the postal-code border than what Google Maps shows.

For example:

- [12053: our own postal-code area](https://maps.app.goo.gl/USz9zGJG3wBtwzez9)
- [12049: west of us, including Schillerkiez and Hasenheide](https://maps.app.goo.gl/3TA6373gXNQdKdch6)
- [12043: our northeastern neighbour between Karl-Marx-Straße and Sonnenallee](https://maps.app.goo.gl/k

Gu58pws

Znp

Yo

Z3H6)
- [12051: our neighbour to the south, around Hermannstraße](https://maps.app.goo.gl/4bRYvDfVJccrDriE8)

At a 1 km radius, the result expands across more of Neukölln and starts touching Kreuzberg:

![Mittelweg 50, 12053 Berlin with postal code areas in a 1000m radius](/_astro/radius_1000m.CpcdF0_0.png)

```json
[
  { "areacode": "12053", "name": "Berlin Neukölln", "intersection_ratio": 0.998097 },
  { "areacode": "12049", "name": "Berlin Neukölln", "intersection_ratio": 0.845081 },
  { "areacode": "12043", "name": "Berlin Neukölln", "intersection_ratio": 0.799153 },
  { "areacode": "12051", "name": "Berlin Neukölln", "intersection_ratio": 0.393221 },
  { "areacode": "12045", "name": "Berlin Neukölln", "intersection_ratio": 0.08642 },
  { "areacode": "12055", "name": "Berlin Neukölln", "intersection_ratio": 0.102147 },
  { "areacode": "12059", "name": "Berlin Neukölln", "intersection_ratio": 0.002794 },
  { "areacode": "10965", "name": "Berlin Kreuzberg", "intersection_ratio": 0.020059 }
]
```

Again, the Google Maps comparison is useful as a sanity check:

- [12049: still Neukölln](https://maps.app.goo.gl/3TA6373gXNQdKdch6)
- [10965: hello Kreuzberg](https://maps.app.goo.gl/j68aFjnj8JDNGNph8)

The important part is the `intersection_ratio`.

It gives the consuming product more room to make its own decision. Maybe every matching postal code should be shown. Maybe tiny overlaps should be hidden, depending on the product flow.

The service does not have to hard-code all of that business logic. It returns enough information for the product to decide.

The endpoint returns GeoJSON-compatible data, so we could visualize the results comfortably with [geojson.io](https://geojson.io). That made validation very practical: run the query, inspect the postal-code areas on a map, compare them with local geography, and check whether the results make sense.

This was also useful for catching the kind of edge cases that are easy to miss in pure code. Postal-code borders do not always behave the way you expect from looking at a normal map.

#### How it performed

The target was sub-second response time per request.

The benchmark below was run locally on an Apple M1 with 200 requests, concurrency of 10, and a short warmup. The endpoint stayed around the 600 ms mean latency range even for larger radii.

| Radius | Mean latency |       p50 |       p95 |        p99 |   Throughput |
| ------ | -----------: | --------: | --------: | ---------: | -----------: |
| 20 km  |    592.89 ms | 571.86 ms | 815.63 ms | 1117.56 ms | \~16.7 req/s |
| 50 km  |    622.27 ms | 616.30 ms | 821.18 ms |  973.43 ms | \~16.0 req/s |
| 100 km |    599.29 ms | 573.89 ms | 856.49 ms | 1143.87 ms | \~16.6 req/s |

At roughly 600 ms mean latency, even for a 100 km search radius, the result was comfortably within the target range.

More importantly, the service reached the balance we were looking for:

- accurate enough for the product requirement
- fast enough for interactive use
- simple enough to operate
- independent from a broad GIS SaaS contract
- flexible enough to evolve if the product requirements change

#### Conclusion

Full GIS platforms are valuable when the product needs the breadth they provide. For our requirement, we did not need a full location-intelligence suite. We needed one well-defined capability: given an address and a radius, return the relevant postal codes.

When the requirement is narrow, stable, and central to the product experience, a focused microservice can be the better engineering choice. It avoids unnecessary platform complexity, keeps costs proportional to actual usage, and gives the product team full control over behavior, thresholds, data sources, and integration.

The work is not trivial. Geospatial data has edge cases. Postal-code areas are messy. Accuracy and performance need to be balanced deliberately. But with the right architecture, a complex domain can still produce a small, maintainable service.

Build the part the product needs. Nothing more, nothing less.

We are happy to consult, design or develop your next microservice.

[[sales@k3b.de](mailto:sales@k3b.de)](`mailto:sales@k3b.de`)

## Further information

[OpenStreetMap](https://www.openstreetmap.de/)

Free, community-funded map data.

[postleitzahlen by Sebastian Vollnhals](https://github.com/yetzt/postleitzahlen)

German postal-code boundaries derived from OpenStreetMap via Overpass API.

[Berlin Social Science Center postal-code centroid dataset](https://github.com/WZBSocialScienceCenter/plz_geocoord)

German postal codes and their centroid coordinates.

[Geofabrik](https://download.geofabrik.de/europe/germany.html)

Nightly OpenStreetMap extracts for Germany and other regions.

[GeoJSON](https://geojson.org)

Format for encoding geographic data structures.

[geojson.io](https://geojson.io)

Web-based viewer for GeoJSON data.