Nakodo's business campaigns find local businesses to email: the kinds you choose, in the places you choose. They come from the Overture Maps places data, an open listing released each month, and the default setting is "Independent only", because a brand selling a booking system to independent pubs has nothing to say to a Wetherspoons branch.

So something has to decide what a chain is. Overture has a brand field for exactly this. It is not enough, and I can show you by how much, with a query you can run yourself in about 20 seconds.

The rule

It is two conditions, and it is written on our public how it works page in one sentence: a listed brand, or a website shared by more than 3 places, counts as a chain.

const SHARED_SITE = 3; // more places than this on one website: a chain's (or a directory's) // ... const chain = Boolean(r.brand) || (r.host !== null && r.sharing > SHARED_SITE); 

The second half is the one that earns its keep. A shared website means the place does not control its own web presence, which is true of a chain branch, and also true of a market stall whose listed website is the market's, or a cafe inside a department store. All of those are wrong to email as an independent business, and all of them are wrong to read for an email address, because the address you find belongs to somebody else.

Measuring it on real data

Overture publishes GeoParquet on a public S3 bucket. No account, no credentials, no sign up. You need the DuckDB CLI and this, which counts cafes and coffee shops in a half degree cell containing Manchester, in the release that was current when I wrote this:

SET s3_region='us-west-2'; CREATE TABLE cafes AS SELECT names."primary" AS name, brand.names."primary" AS brand, nullif(lower(regexp_extract(websites[1], '^https?://(?:www\.)?([^/:?#]+)', 1)), '') AS host FROM read_parquet('s3://overturemaps-us-west-2/release/2026-09-23.1/theme=places/type=place/*.parquet') WHERE bbox.xmin >= -2.5 AND bbox.xmax <= -2.0 AND bbox.ymin >= 53.0 AND bbox.ymax <= 53.5 AND confidence >= 0.6 AND list_has_any(taxonomy.hierarchy, ['cafe', 'coffee_shop']); SELECT count(*) AS cafes, count(host) AS with_a_website, count(brand) AS with_a_brand FROM cafes; SELECT host, any_value(brand) AS brand, count(*) AS places FROM cafes WHERE host IS NOT NULL GROUP BY host HAVING count(*) > 3 ORDER BY places DESC LIMIT 10; 

Measured on 7 October 2026 from a laptop, the whole thing in 22.9 seconds including the download of the row groups it needed:

┌───────┬────────────────┬──────────────┐ │ cafes │ with_a_website │ with_a_brand │ ├───────┼────────────────┼──────────────┤ │ 1499 │ 1096 │ 242 │ └───────┴────────────────┴──────────────┘ ┌───────────────────────┬──────────────┬────────┐ │ host │ brand │ places │ ├───────────────────────┼──────────────┼────────┤ │ costa.co.uk │ Costa Coffee │ 113 │ │ starbucks.co.uk │ Starbucks │ 63 │ │ caffenero.com │ Caffè Nero │ 25 │ │ costaireland.ie │ Costa Coffee │ 16 │ │ caffenero.co.uk │ Caffè Nero │ 11 │ │ linktr.ee │ Starbucks │ 9 │ │ instagram.com │ NULL │ 7 │ │ twitter.com │ NULL │ 6 │ │ marksandspencer.com │ NULL │ 6 │ │ storelocator.asda.com │ Asda │ 5 │ └───────────────────────┴──────────────┴────────┘ 

1,499 cafes, 242 with a brand. The shared website rule flags 318 of them, and 163 of those 318 have no brand at all. That is the number that decided the feature: more than half of what the rule catches is invisible to the field that exists for the purpose.

The tail of that table is the other half of the comment. linktr.ee, instagram.com and twitter.com are not chains. They are what a small business puts in the website slot when it does not have a website, and they fail the same test for the same reason: reading that URL for a contact address gives you Instagram's, not theirs. marksandspencer.com is a concession counter inside a shop. storelocator.asda.com is a brand's own store finder, which is a website nobody at that branch reads.

Two rows show the rule's limits honestly. caffenero.com and caffenero.co.uk are one brand counted as two hosts, and costa.co.uk and costaireland.ie likewise. Counting per host, not per brand, splits them. With a threshold of 3 that does not matter, because both halves are still far over it, but it is worth knowing the rule is about websites and not about companies.

Where the window function goes

In production the same count runs in Postgres, over a table of imported places, and the placement of it is the only subtle part:

const HOST = sql<string | null>`lower(substring(${places.website} from '^https?://(?:www\\.)?([^/:?#]+)'))`; const candidates = db .select({ id: places.id, // ... host: HOST.as("host"), // Counted over every candidate, before any are left out. sharing: sql<number>`(count(*) over (partition by ${HOST}))::int`.as("sharing"), }) .from(places) .where(and( arrayOverlaps(places.categories, categories), gte(places.confidence, MIN_CONFIDENCE), or(isNull(places.operatingStatus), eq(places.operatingStatus, "open")), inLocations(targeting), )) .as("candidates"); 

The window is in the inner select, alongside the filters that define what the campaign is looking at, and the chain filter happens outside it. Window functions are evaluated after WHERE, so if you push the chain test into that same WHERE you get a count of the rows that survived your own filter, which is circular. And if you push it the other way, into a view over the whole places table, the count becomes global rather than per campaign, which sounds better and is worse: a campaign looking for cafes in one city should judge "shared" against the cafes in that city, not against every row we have ever imported for anyone.

The trade-off that falls out of that choice is real and we accept it. A three branch chain with two branches in the campaign's area is under the threshold here. It almost certainly has a brand, which is why the rule is an || and not a replacement.

The outer select then skips what this campaign has already seen, with an anti-join rather than a NOT IN:

.where(sql`not exists (select 1 from ${campaignChannels} cc where cc.campaign_id = ${campaignId} and cc.channel_id = 'web:' || ${candidates.id})`) 

The 'web:' || id is how a place becomes a channel id in a schema where a channel might be a YouTube channel, an Instagram account or a business, so one campaign's list can hold all of them.

Nearest first, without PostGIS

The campaign takes up to 200 new businesses a day, nearest first. There is no PostGIS in this database, and there does not need to be, because ordering does not require a distance, only something monotonic in it:

const order = points.length ? sql`least(${sql.join(points.map((l) => sql`power(${candidates.lat} - ${l.lat}, 2) + power((${candidates.lng} - ${l.lng}) * ${Math.cos((l.lat! * Math.PI) / 180)}, 2)`, ), sql`, `)})` : sql`${candidates.confidence} desc nulls last`; 

Squared degrees, with the longitude term scaled by the cosine of the latitude so a degree of longitude in Manchester is not treated as a degree of latitude. least(...) over every point the campaign chose, so a campaign covering three cities orders by the distance to whichever of them is closest. No sqrt, no haversine, no extension, and the ordering is identical to the one a real distance would give at these scales. A campaign targeting a whole country has no point to measure from, so it falls back to Overture's own confidence.

The actual kilometres are then computed in TypeScript for the rows that come back, because that number is shown to the customer and stored on the row, and at that point there are a few hundred of them rather than a few million.

Which is also why the query asks for four times what it needs:

.orderBy(order) .limit(limit * 4); 

The SQL filter is a bounding box, the campaign's area is a circle, and the chain rule drops rows after the fact. Over fetching by four, then filtering exactly in TypeScript, then sorting by true distance and slicing, costs one extra round of rows and removes an entire category of "the page came back short" bug.

The quirk that will bite you if you try this

If you run the query above without the nullif, you get a chain with thousands of branches. regexp_extract returns the empty string when nothing matches, and over every category in that same cell, 6,558 places share that empty host. 6,188 of them have a website field that is literally the empty string. The rest have a value with no scheme, like www.manchesterairport.co.uk, which an anchored ^https?:// pattern will not touch.

Postgres is kinder here: substring(... from '...') returns NULL when the pattern does not match, so the production rule's r.host !== null check handles it. DuckDB's regexp_extract does not, and an empty string partitions perfectly well. One nullif is the difference between a chain detector and a very confident lie.

If you want the rest of the setup, the privacy notice names exactly which Overture fields we hold about a listed business, the terms carry the data credits, and how it works has the customer facing version of everything above, including the 200 a day and the chain sentence. The data itself is CDLA-Permissive 2.0, Apache 2.0 and CC0, which is why a startup can build on it and why this post can hand you the query.