A black-and-white photograph of a city. By Simon Infanger
Postal code radius search
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
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
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.
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.
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, 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 from the Berlin Social Science Center 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 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:
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
[
  { "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:
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
[
  { "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:
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. 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.
RadiusMean latencyp50p95p99Throughput
20 km592.89 ms571.86 ms815.63 ms1117.56 ms~16.7 req/s
50 km622.27 ms616.30 ms821.18 ms973.43 ms~16.0 req/s
100 km599.29 ms573.89 ms856.49 ms1143.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.
More Posts
Contact
Share your ideas and challenges with us – we look forward to meeting you.
info@k3b.de