Shipmind Labs

Overlapping delivery zones resolve by explicit priority

· 8 min read

Two zones covering the same street is normal: a city hub reaches it, and the store's own ring reaches it too. The overlap is fine. What goes wrong is the resolution, because most implementations return the first polygon a loop matched, which quietly promotes the order rows came out of a file into a business rule nobody wrote down and nobody can test.

We keep meeting this shape in delivery work: dark-store marketplaces with role-specific apps for couriers, warehouse staff and store operations, and cross-border commerce where the carrier and the fee follow from the address. The part that never changes we pulled into a small library of our own, delivery-zones (https://github.com/shipmindlabs/delivery-zones). The rule it enforces is what this post is about: an overlap has to be a modelled decision, and the model is a priority a reader can find.

Insertion order is a rule you did not write

The honest primitive is a lookup that returns everything. You ask which zones reach an address and you get every match, in the order the zones were given. That is the truth about the catalogue, and it is also where the bug starts, because the call site is usually matches[0].

Once that index exists in application code, the loader owns dispatch. Someone edits a zone and re-saves it, so it moves to the end of the file, and the fee tier for one street changes with no diff in any rule. Or a staging catalogue gets seeded in a different order than production, so the two environments route the same address to different stores and both look correct in isolation. Nothing raises. Nothing is logged. The failure is a customer paying the wrong delivery fee, and the code review that would have caught it never saw a line worth questioning.

Sorting the matches inside the lookup does not fix this, it only moves the implicit rule one level down. The fix is that the zone carries the rank, and the caller names the policy that uses it.

A zone is a polygon plus the metadata a caller acts on

Zones are read from GeoJSON features and held in memory, so no database is involved. Each one carries what a dispatcher actually asks about an address: which store serves it, what tier the delivery falls into, when the area accepts orders, and how it ranks against its neighbours.

python
from datetime import datetime

from delivery_zones import Zone

zone = Zone.from_geojson(
    {
        "type": "Feature",
        "id": "downtown",
        "geometry": {
            "type": "Polygon",
            "coordinates": [
                [
                    [13.37, 52.51],
                    [13.42, 52.51],
                    [13.42, 52.54],
                    [13.37, 52.54],
                    [13.37, 52.51],
                ]
            ],
        },
        "properties": {
            "store_id": "store-1",
            "fee_tier": "standard",
            "priority": 10,
            "store_location": [13.39, 52.52],
            "service_hours": [
                {"weekday": "mon", "opens": "09:00", "closes": "21:00"}
            ],
        },
    }
)

zone.priority                                    # 10, or 0 when undeclared
zone.is_open_at(datetime(2026, 8, 24, 10, 0))    # True, a Monday morning

The default matters more than it looks. A zone that declares nothing sits at priority 0, so a catalogue that has never thought about overlaps behaves as a flat set of equals, and equals are ambiguous rather than silently ordered. The absence of a decision stays visible instead of being papered over by whichever zone happened to be parsed first.

The ranking is an argument, and the tie is a different argument

Which zone wins is a business rule, so it belongs at the call site, spelled out:

python
from delivery_zones import TieBreak, ZoneIndex, by_priority, by_smallest_area

index = ZoneIndex.from_geojson(feature_collection)

index.zones_containing(address)                                    # every match
index.zones_containing(address, policy=by_priority(TieBreak.RAISE))
index.zones_containing(address, policy=by_smallest_area(TieBreak.FIRST))

Two rankings cover most catalogues. by_priority ranks on the zone's declared priority, higher first, which is what you want when the hierarchy is commercial, like a promoted hub that should take a district off the neighbouring stores. by_smallest_area prefers the tightest coverage, usually the most specific zone, and that fits when the hierarchy is geographic and nobody wants to maintain numbers by hand.

The second argument is the one we care about most, because it is the one every home-grown implementation gets wrong by omission. How you rank and what you do when the leaders rank equally are separate questions, so the tie-break is required rather than defaulted. TieBreak.FIRST keeps the first zone in declaration order, still insertion order, but now it is a choice somebody typed. TieBreak.ALL returns every leader and hands the decision back to the caller, which is what you want when the answer can legitimately be two stores. TieBreak.RAISE raises AmbiguousCoverage, so a bad catalogue surfaces instead of routing at random.

We deliberately ship no default here. A default tie-break is the same bug as insertion order, only harder to see, because it has a respectable name.

A policy is a callable, so your rule fits the same slot

Ranking by priority or area does not exhaust what "who serves this address" means. A policy is just a callable from matched zones to chosen ones, and every policy returns a tuple, empty when nothing covers the address. So a rule of your own drops into the same argument:

python
from datetime import datetime


def open_at(moment):
    def policy(zones):
        return tuple(zone for zone in zones if zone.is_open_at(moment))

    return policy


index.zones_containing(address, policy=open_at(datetime(2026, 8, 24, 10, 0)))

Zones without declared service hours count as open, so a catalogue that says nothing about hours does not collapse to empty coverage the first time someone uses this. When the rule is "highest or lowest something", ranked_by builds the policy out of any measure of a zone, and the tie question comes along with it.

Worth noticing: composing two rules is itself a decision. Filtering to the open zones and then ranking gives a different answer than ranking and then filtering. In the first, the city hub takes over the street when the local store closes. In the second, the street simply has no coverage at 22:00. Both are defensible, and neither should be an emergent property of the order two helpers were called in. Put the composition inside one named policy so it is one thing with one test.

The overlaps you can see and the holes you cannot

Overlap resolution and coverage gaps are two halves of the same question, which zone serves this point, and whether any zone does. An overlap shows up the moment you look at a lookup result. A gap shows up when an order fails. A scan walks a lattice over the served area, asks the index about every position and groups the misses:

python
from delivery_zones import scan_coverage

report = scan_coverage(index, spacing=0.002)

assert report.is_complete, report.describe()

report.gaps[0].size              # how many sampled positions this gap holds
report.gaps[0].representative    # a position inside it, ready to paste into a bug
report.gaps[0].bbox              # where to look on a map

That assertion belongs in the catalogue's own test suite, next to the priority assertions. A strip between two rings, or a hole cut around a lake, then fails a test run instead of an order. spacing decides what the scan can see: a gap narrower than the lattice slips between samples, while a fine spacing over a whole city costs many lookups, which is why the scan refuses more than a million positions. A sample landing exactly on a border may fall either way, so it is probably worth choosing a spacing that does not align with your rings. By default the scan covers the envelope of the indexed zones, and passing an explicit bounding box is how you check a district the catalogue is supposed to reach but may have forgotten entirely.

What it costs to run

The index prepares the polygons once and buckets them into a grid, so a lookup does not walk the whole catalogue. Everything is in memory and read from GeoJSON, which makes the catalogue a deploy artifact: cheap at request time, and reloaded when it changes rather than queried.

The recurring cost is editorial, not computational. Someone has to own the priority numbers, and leaving gaps between them makes inserting a zone later an edit to one feature rather than a renumbering. Our own split is to run the strict tie-break in the catalogue's test suite, where AmbiguousCoverage is a build failure and a named engineer fixes the feature, and a ranked policy in dispatch, where a request needs an answer. Ambiguity then becomes something the catalogue owner resolves on a Tuesday afternoon, not something dispatch improvises at peak hours.

The library is pre-alpha and the public API is not stable yet. The rule underneath it holds anyway: if two zones cover the same address, either the catalogue says which one wins, or the code says it does not know. Insertion order is not a third option, it is the first one, written by accident.

Was this useful?

Building something similar?

or email hello@shipmindlabs.com