devsnack

Delivery Zones in PostGIS: Stop Storing a Radius

A circle accepts the flat across the river and refuses the estate down the dual carriageway. Polygons, geography over geometry, and why ST_Contains is the wrong function.

DevSnack25 Aug 2026 · 12 min

The first version of a delivery area is a number. Five kilometres. It goes in a column next to the restaurant, the checkout compares it against the customer's coordinates, and the whole feature ships in an afternoon. It is also the version that has a courier phoning the office to ask how they are supposed to get to an address that the app cheerfully accepted.

A radius is a claim about geometry, and a delivery area is a claim about roads. Those two things agree in the middle of a grid-planned city and nowhere else. Rivers, railways, dual carriageways, estuaries and one-way systems all produce addresses that are close on a map and expensive to reach, and a circle cannot express any of them.

What a radius actually asserts

Take one restaurant and three customers, each within a plausible five-kilometre setting:

                          straight line   drive   in radius?   in zone?
cafe across the street         0.2 km        3 min       yes         yes
flat across the river          4.1 km       26 min       yes          no
estate down the dual road      6.3 km        9 min        no         yes

The radius is wrong twice, and wrong in both directions. It accepts a delivery that will take twenty-six minutes and one bridge, and it refuses one that is nine minutes down a fast road. Widening the radius to catch the third address also catches everything else at 6.3 kilometres, including more of the wrong side of the river.

No value of the number is correct, because the number is the wrong shape. What you want is the shape a human would draw with local knowledge — and once you accept that, the storage question answers itself.

Geography or geometry, decided once

PostGIS gives you two spatial types and the choice matters more than any other decision in this post. geometry treats coordinates as points on a flat plane, in whatever units the SRID implies. For raw latitude and longitude that means degrees, so a distance of 0.05 means five hundredths of a degree, which is a different distance in Aberdeen than in Lagos.

geography treats them as points on a sphere and works in metres everywhere. It is slower, and for anything the size of a city that difference is not measurable. Store lat/lng data as geography with SRID 4326 and you will never write a unit conversion or debug a distance that is correct in one country.

The trap that follows from this: ST_Contains does not accept geography arguments. Reach for it out of habit and Postgres either errors or silently casts to geometry and answers a different question. The geography-aware containment function is ST_Covers, and it has the boundary semantics you want anyway — a point exactly on the edge of a zone is covered by it, where ST_Contains would call the edge outside.

A table, a polygon and an index

supabase/migrations/0003_zones.sql
create table delivery_zones (
  id            uuid primary key,
  vendor_id     uuid not null references vendors(id) on delete cascade,
  name          text not null,
  -- The shape itself. 4326 is lat/lng; geography gives us metres.
  area          geography(Polygon, 4326) not null,
  fee_cents     integer not null,
  min_order_cents integer not null default 0,
  -- Lower wins where zones overlap. See below — this column is
  -- the difference between a deterministic answer and a coin toss.
  priority      integer not null default 100,
  active        boolean not null default true
);

-- Without this the query below is a sequential scan over every
-- polygon you have ever drawn.
create index delivery_zones_area_idx on delivery_zones using gist (area);
create index delivery_zones_vendor_idx on delivery_zones (vendor_id) where active;

The GiST index is not optional at any real size. A spatial index stores bounding boxes, so a containment query first discards every polygon whose box cannot contain the point — which is nearly all of them — and only then does the expensive edge arithmetic on the handful left.

Polygons arrive from whatever your admin draws with, usually as GeoJSON, and humans drawing on maps produce self-intersecting rings with cheerful regularity. ST_MakeValid on the way in costs nothing and turns a class of runtime errors into a shape that is merely slightly wrong.

Ask the only question that matters

With the shape stored, the checkout question is a single round trip. Not "how far away is this address" but "which zone is it in, and what does that zone charge".

lib/delivery/resolve-zone.sql
select z.id, z.name, z.fee_cents, z.min_order_cents
from delivery_zones z
where z.vendor_id = $1
  and z.active
  -- ST_Covers, not ST_Contains: this column is geography, and a
  -- point on the boundary belongs to the zone.
  and ST_Covers(z.area, ST_MakePoint($3, $2)::geography)
order by z.priority asc
limit 1;

Note the argument order on ST_MakePoint. It takes x then y, which is longitude then latitude — the reverse of how every mapping API, address form and human being writes a coordinate. Passing them the wrong way round does not error. It puts your London customer in the Indian Ocean, the query returns no rows, and the symptom is "delivery is unavailable everywhere".

The fee comes back with the answer. That matters more than it looks: the alternative is resolving the zone and then looking up its price separately, which is two queries and one opportunity for the checkout total to disagree with the order record.

Overlaps need a deterministic winner

Zones overlap constantly, and usually on purpose. A vendor wants a cheap, tight core zone and a wider expensive one around it, and the natural way to draw that is two shapes where the second contains the first. An address in the middle satisfies both.

Without an explicit ordering the database is entitled to return either, and it will return whichever the plan happens to reach first — which can change when the table grows or the index is rebuilt. The customer sees a £2 fee on Monday and £4 on Tuesday from a codebase nobody touched. The priority column plus order by is the whole fix, and it is cheaper than the alternative of computing the smallest area at query time.

Resist the temptation to make overlaps impossible by validating that new zones do not intersect existing ones. Overlap is a legitimate way to express tiered pricing, and the validation you are imagining takes an afternoon to write and a year to escape.

What this does not solve

A zone answers whether you deliver somewhere and what you charge. It says nothing about which courier should take the order, or how long the journey will be — that is a per-pair question, it needs road distances rather than shapes, and the dispatch post covers it. Be honest about the boundary between the two, because a polygon is not a routing engine and treating it as one is how a delivery area quietly becomes a promise about arrival times.

Nor does it help with addresses your geocoder places badly. A point is only as good as the coordinates you resolved it to, and a rural postcode that geocodes to the centre of a large area will fall in or out of a zone more or less at random. That is a geocoding problem, and no amount of polygon precision improves it.

Draw zones from deliveries, not from a compass

Start each vendor with one polygon that a person who knows the area drew by hand, and treat the initial shape as a guess. Then let the data correct it: log the drive time of every completed delivery against the zone it came from, and look at the tail. The addresses that consistently take twice the median are the piece of the polygon to cut, and the refusals clustered just outside one edge are the piece to add.

A radius cannot be edited this way, which is the deeper reason to stop storing one. A shape can absorb everything you learn about a city. A number can only get bigger or smaller, and both of those make it wrong somewhere else.

Feastly — $39

Restaurant coverage, delivery fees and rider dispatch already modelled together, so a zone is something you draw in the admin rather than a constant compiled into the app.

See the live demo