<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Théo Mathieu</title>
    <description>The latest articles on DEV Community by Théo Mathieu (@mokto).</description>
    <link>https://dev.to/mokto</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4244758%2F55db95b6-8de8-4678-ada3-0e18f658e609.jpg</url>
      <title>DEV Community: Théo Mathieu</title>
      <link>https://dev.to/mokto</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/mokto"/>
    <language>en</language>
    <item>
      <title>What "accurate" means for a geocoder, and how I decide a change is safe</title>
      <dc:creator>Théo Mathieu</dc:creator>
      <pubDate>Sat, 14 Nov 2026 19:54:27 +0000</pubDate>
      <link>https://dev.to/mokto/what-accurate-means-for-a-geocoder-and-how-i-decide-a-change-is-safe-4aoo</link>
      <guid>https://dev.to/mokto/what-accurate-means-for-a-geocoder-and-how-i-decide-a-change-is-safe-4aoo</guid>
      <description>&lt;p&gt;I run OpenGeocoding. Originally published on &lt;a href="https://opengeocoding.com/blog/how-geocoding-accuracy-is-measured" rel="noopener noreferrer"&gt;the OpenGeocoding blog&lt;/a&gt;. Cross-post formatting was prepared with AI assistance.&lt;/p&gt;

&lt;p&gt;“How accurate is it?” sounds like one question, but a geocoder answers several at once. Did it find anything? Is the answer in the right country? Is the point close enough to be useful? Is it as precise as the address you sent, a house for a house and a town for a town? And when it returns a house number or a postcode, does that agree with yours?&lt;/p&gt;

&lt;p&gt;This post explains how I score those questions for OpenGeocoding, how close I count as close enough, which losses I accept when I change the engine, and how I keep the test set from grading the index on its own data. The &lt;a href="https://opengeocoding.com/blog/building-opengeocoding" rel="noopener noreferrer"&gt;first post&lt;/a&gt; has the pass rates; this one is about what stands behind them.&lt;/p&gt;

&lt;h2&gt;
  
  
  A pass has several conditions
&lt;/h2&gt;

&lt;p&gt;The scorer looks only at the first result. A best-of-several answer would flatter every service, and an application usually takes the first one. It ignores the confidence each service reports, because those numbers don’t mean the same thing from one provider to the next. A case then passes when the answer meets all of these, taken from the case’s expectation (&lt;a href="https://github.com/Mokto/opengeocoding/blob/6812d06d7bfe9b96654b1fbc9dae90d77eb20c65/SCORECARD.md#what-a-pass-means" rel="noopener noreferrer"&gt;what a pass means&lt;/a&gt;):&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;the country is one of the allowed countries;&lt;/li&gt;
&lt;li&gt;the point is within the case’s distance tolerance;&lt;/li&gt;
&lt;li&gt;the precision is within the allowed range, so a house case can’t pass on a street;&lt;/li&gt;
&lt;li&gt;a house number or postcode it returns agrees with the request.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Failures are not one bucket either. The &lt;a href="https://github.com/Mokto/opengeocoding/blob/58c30c6225165a3cf6175abc13826b7f3b48c506/docs/evaluation/README.md#gold-corpus" rel="noopener noreferrer"&gt;evaluation guide&lt;/a&gt; separates &lt;code&gt;too_coarse&lt;/code&gt; (right country or region, but not the precision the address asked for), &lt;code&gt;abstain&lt;/code&gt;, &lt;code&gt;near_miss&lt;/code&gt;, &lt;code&gt;far_miss&lt;/code&gt; (more than 100 km away), &lt;code&gt;wrong_country&lt;/code&gt; and a few others. They matter because they aren’t equally bad. A country answer to a street address is a different failure from a point in another country, and the second is the more serious one.&lt;/p&gt;

&lt;h2&gt;
  
  
  How close is close enough
&lt;/h2&gt;

&lt;p&gt;The tolerance depends on what the case asks for, and the corpus records it per case instead of using one global number:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Registry houses:&lt;/strong&gt; the tolerance is recorded on each case. The US sample uses 150 m, or 300 m where the register places the point at a parcel centroid, and a separate sample of US addresses with loosely placed points is scored at 500 m and reported apart (&lt;a href="https://github.com/Mokto/opengeocoding/blob/6812d06d7bfe9b96654b1fbc9dae90d77eb20c65/docs/evaluation/registry/us.md" rel="noopener noreferrer"&gt;US sample&lt;/a&gt;).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Landmarks:&lt;/strong&gt; 1 km. Those cases measure coarse coverage, not the position of an entrance.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Company-address town cases:&lt;/strong&gt; 5 km, rising to 10 km from 50,000 people and 20 km from a million (&lt;a href="https://github.com/Mokto/opengeocoding/blob/58c30c6225165a3cf6175abc13826b7f3b48c506/docs/evaluation/README.md#gleif-addresses-at-town-level" rel="noopener noreferrer"&gt;GLEIF cases&lt;/a&gt;).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Indonesian villages:&lt;/strong&gt; twice the radius of a circle with the village’s area, between 2 and 25 km.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of this tells you whether a point is the front door. The &lt;a href="https://github.com/Mokto/opengeocoding/blob/6812d06d7bfe9b96654b1fbc9dae90d77eb20c65/SCORECARD.md#what-a-pass-means" rel="noopener noreferrer"&gt;scorecard&lt;/a&gt; says so directly: the pass rates don’t measure postal deliverability, entrance accuracy or unit correctness. Whether 150 m is good enough depends on your workload. For enriching company records with a town and a region it is more than you need. For routing a courier to a flat it is not enough.&lt;/p&gt;

&lt;h2&gt;
  
  
  Which losses I accept when I change the engine
&lt;/h2&gt;

&lt;p&gt;A pass rate says how good the engine is. A change needs a different question: what did it break? Every pull request that touches the service, its data or the test corpus is evaluated against the commit it branched from, on the same index, and the two runs are compared case by case (&lt;a href="https://github.com/Mokto/opengeocoding/blob/58c30c6225165a3cf6175abc13826b7f3b48c506/docs/evaluation/README.md#gates" rel="noopener noreferrer"&gt;gates&lt;/a&gt;).&lt;/p&gt;

&lt;p&gt;The rule has one exception:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A case that passed before and fails now fails the change, as does a case that moves to a worse failure class and a new critical result.&lt;/li&gt;
&lt;li&gt;The exception is the panel built from OpenStreetMap addresses, which measures agreement with data the index itself reads. There I accept at most 5 locations that newly fail, and only when at least 5 times as many newly pass and nothing else fails.&lt;/li&gt;
&lt;li&gt;Losses on registries, Wikidata, GLEIF and constructed cases are never traded for gains elsewhere, because those measure accuracy against sources outside the index.&lt;/li&gt;
&lt;li&gt;Speed is a separate gate: the change fails if SearchAddress, SuggestAddress or ReverseGeocode gets more than 15% slower in total, at p50, p90 or p99.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So the acceptable loss is zero where the evidence is independent, and a small, explicit trade where it is not. Two experiments in the engine’s notes show why I keep it strict. A change to town matching reached 2,360 passing towns, but introduced 52 regressions where a coarse answer or an abstention became a point more than 5 km from the expected one. I dropped it. The version I kept finds 1,880 and, in the local comparison, adds no newly failing check. Another prototype gained 17 passes by trusting a place-type word alone, and it selected the wrong Samoa namesake, so I rejected that too.&lt;/p&gt;

&lt;p&gt;A gate like this measures change, not accuracy. It tells me a commit didn’t make the cases I know about worse. It says nothing about the addresses nobody wrote a case for.&lt;/p&gt;

&lt;h2&gt;
  
  
  Not grading the index on its own data
&lt;/h2&gt;

&lt;p&gt;A geocoder tested on the data it was built from will look good. The corpus has several defences against that, and each has a limit.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Expected answers come from outside the engine.&lt;/strong&gt; Wikidata, national address registries, the US Census geocoder and similar sources provide them, never Nominatim, another geocoder or this service. GeoNames and Overture, which the index also reads, only chose which places to ask about.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Registry samples start from the registry.&lt;/strong&gt; Cases first drawn from the index’s own Overture data and then checked against a register can’t show an address the index lacks, so recall stays invisible. The registry samples are drawn from the register first, and the index is asked about whatever was drawn (&lt;a href="https://github.com/Mokto/opengeocoding/blob/58c30c6225165a3cf6175abc13826b7f3b48c506/docs/evaluation/README.md#registry-first-samples" rel="noopener noreferrer"&gt;registry-first samples&lt;/a&gt;).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Overlap is reported, not hidden.&lt;/strong&gt; Several registries used as expectations are also inputs to the index, so in those countries a pass measures fidelity to the register. The OpenStreetMap panel measures agreement with the index’s own source. Wikidata towns partly come from GeoNames, so some town gains reflect a shared origin. The reports group results by kind of evidence so the independent sources are not blended with the self-referential ones.&lt;/p&gt;

&lt;h2&gt;
  
  
  Balance, and how much a number can carry
&lt;/h2&gt;

&lt;p&gt;Counts per country are not equal, and the headline rate depends on that. Town cases take up to 100 settlements per country, company-address cases up to 50 and the landmark house-coverage cases 20, so no country dominates those groups. The registry samples differ in size: from 120 cases for French Polynesia to 2,794 for South Korea, and they make up most of the 29,013-query selection that the &lt;a href="https://opengeocoding.com/blog/building-opengeocoding" rel="noopener noreferrer"&gt;published pass rate&lt;/a&gt; is computed on. That is why the scorecard also breaks results down by cohort and by registry, and why the cohorts differ in level and difficulty.&lt;/p&gt;

&lt;p&gt;Variants of one location (a typo, a missing country) share a key, so they don’t count as independent places. About one location in five is held out from development and reported separately. With around 250 cases in a slice, the evaluation guide notes, an aggregate rate carries several points of uncertainty, and a single country’s row is one piece of evidence rather than a rate. I read the per-country tables that way, and I’d ask you to as well.&lt;/p&gt;

&lt;p&gt;One more limit applies to all of it: this is a vendor-run evaluation on my own corpus, as the &lt;a href="https://github.com/Mokto/opengeocoding/blob/6812d06d7bfe9b96654b1fbc9dae90d77eb20c65/SCORECARD.md" rel="noopener noreferrer"&gt;scorecard&lt;/a&gt; states at the top, and human review of the cases is still pending.&lt;/p&gt;

&lt;h2&gt;
  
  
  What to do with this
&lt;/h2&gt;

&lt;p&gt;Use the same method on a sample of your own addresses. Decide first what close enough means for your workload: a town for enriching profiles, a house within 150 m for deliveries. Then geocode a few hundred addresses from each country you care about and look at the failures, not only the rate. The free plan has 50,000 requests a month with no card, and the &lt;a href="https://opengeocoding.com/bulk-geocoding" rel="noopener noreferrer"&gt;bulk geocoding guide&lt;/a&gt; has a script that geocodes a CSV. The methodology is in the &lt;a href="https://github.com/Mokto/opengeocoding/blob/58c30c6225165a3cf6175abc13826b7f3b48c506/docs/evaluation/README.md" rel="noopener noreferrer"&gt;evaluation guide&lt;/a&gt;, and if a country of yours behaves worse than these tables suggest, I’d like to hear about it.&lt;/p&gt;

</description>
      <category>geocoding</category>
      <category>rust</category>
      <category>programming</category>
    </item>
    <item>
      <title>A coding agent is only as good as its harness</title>
      <dc:creator>Théo Mathieu</dc:creator>
      <pubDate>Sat, 14 Nov 2026 19:49:21 +0000</pubDate>
      <link>https://dev.to/mokto/a-coding-agent-is-only-as-good-as-its-harness-3goe</link>
      <guid>https://dev.to/mokto/a-coding-agent-is-only-as-good-as-its-harness-3goe</guid>
      <description>&lt;p&gt;I run OpenGeocoding. Originally published on &lt;a href="https://opengeocoding.com/blog/a-coding-agent-is-only-as-good-as-its-harness" rel="noopener noreferrer"&gt;the OpenGeocoding blog&lt;/a&gt;. Cross-post formatting was prepared with AI assistance.&lt;/p&gt;

&lt;p&gt;A coding agent can write a fix for a geocoding bug in minutes. The hard part is knowing whether the fix is right, and whether it quietly broke something in another country. That is a question for the harness the agent works in, not for the agent. This post describes the harness around the OpenGeocoding engine: what checks every change, why the world index had to rebuild quickly for any of it to work, and the part that still isn’t stable, which is the speed gate.&lt;/p&gt;

&lt;p&gt;The engine’s public repository merged 281 pull requests between 12 and 27 October 2026, and many of them were written by a coding agent. At that pace, I can’t judge every change by reading it. I need the checks to tell me what the change did to the whole world.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why a geocoder needs this more than most code
&lt;/h2&gt;

&lt;p&gt;Address matching is a long list of rules that interact. The engine has rules for Japanese block addresses, Korean road names, Dutch postcodes, Czech house numbers and many others (&lt;a href="https://opengeocoding.com/blog/building-opengeocoding" rel="noopener noreferrer"&gt;the first post&lt;/a&gt; has a few examples). A rule that helps one country can change the answer for an address somewhere else, and nothing in the code tells you where. Unit tests catch the cases you thought of. What I needed was a check that runs every case I have, in every country, on every change.&lt;/p&gt;

&lt;h2&gt;
  
  
  A world index that rebuilds in minutes
&lt;/h2&gt;

&lt;p&gt;That check is only meaningful on a full world index. A synthetic test world is useful for logic, but the regressions that matter come from real data: a street in Australia that happens to share its name with a town, a Japanese address whose block number sits in a different field than expected.&lt;/p&gt;

&lt;p&gt;So the gate needs the real index, and the base and head of a pull request have to be compared on the same one. Changes to the index format are frequent: 28 commits between 13 and 27 October set a new builder hash, and each of them names a new world index. With an import that takes days, as a Nominatim planet import does, testing each of those changes against the world wouldn’t be practical.&lt;/p&gt;

&lt;p&gt;The engine’s builder produces the world index in about 7 min on one machine (&lt;a href="https://github.com/Mokto/opengeocoding/blob/587f8893f86ccd3b38d55a4350614101d5d5ed73/docs/performance/README.md" rel="noopener noreferrer"&gt;build record&lt;/a&gt;). That number is the foundation of everything below. When a change alters the index, the agent’s instructions say to rebuild it locally and run the comparison there before pushing, rather than wait for CI. On the cluster that runs the CI gate, a first build for a new builder is slower and can take hours, so the local build is what keeps the loop short.&lt;/p&gt;

&lt;h2&gt;
  
  
  Expected answers from registries, never from a geocoder
&lt;/h2&gt;

&lt;p&gt;The gold suite takes its expected answers from official and public reference sources: national address registries, Wikidata, the US Census geocoder and others. They are never taken from Nominatim, another geocoder or this engine’s own output. The cases are generated by a command, &lt;code&gt;evaluate build-gold&lt;/code&gt;, from a list of seeds, and the generated file is never edited by hand. The suite also includes a panel of addresses drawn from OpenStreetMap; since the index reads much of the same data, that panel measures agreement rather than accuracy, and it catches regressions all the same. Today the whole suite has 36,920 cases (&lt;a href="https://github.com/Mokto/opengeocoding/blob/e9e8ed6dc73dd096bc28f801152c580c19f300ce/docs/evaluation/README.md" rel="noopener noreferrer"&gt;evaluation guide&lt;/a&gt;).&lt;/p&gt;

&lt;p&gt;The rules in the engine’s &lt;a href="https://github.com/Mokto/opengeocoding/blob/e9e8ed6dc73dd096bc28f801152c580c19f300ce/AGENTS.md" rel="noopener noreferrer"&gt;AGENTS.md&lt;/a&gt; matter here as much as the data. An agent with a failing check has an easy way out: loosen the expectation. The instructions close that door explicitly. Expectations are never weakened, cases are never dropped, and a failure is never relabelled to make a gate pass. Known failures carry a &lt;code&gt;known_failure&lt;/code&gt; label with their cause, stay visible in every report, and the label comes off only after a fix is verified. About one location in five is held out by a stable hash and kept out of tuning.&lt;/p&gt;

&lt;h2&gt;
  
  
  Base against head, on every pull request
&lt;/h2&gt;

&lt;p&gt;Every pull request that touches the service, its data or the corpus gets a staging image, and its merge base already has one from &lt;code&gt;main&lt;/code&gt;. A private cluster evaluates both images on one full world index and compares the two runs. The result comes back as two comments on the pull request (&lt;a href="https://github.com/Mokto/opengeocoding/blob/e9e8ed6dc73dd096bc28f801152c580c19f300ce/docs/evaluation/README.md#gates" rel="noopener noreferrer"&gt;how the gate works&lt;/a&gt;):&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;gold-full&lt;/strong&gt;, the quality check: passing checks per RPC on both sides, pass rates for each country where anything changed, and the cases that changed. It fails on any case that newly fails, fails in a worse way, or gives a new critical result.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;gold-speed&lt;/strong&gt;, the latency check: SearchAddress, SuggestAddress and ReverseGeocode timings on both sides. It fails when the total, p50, p90 or p99 of any of them is more than 10% slower.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The comparison is relative on purpose. It answers “what did this change do?”, not “how good is the engine?“. A wrong answer counts as worse than no answer, so a case that used to abstain and now returns a nearby but wrong place fails the gate, even when the new answer looks plausible.&lt;/p&gt;

&lt;p&gt;Alongside the gold suite, 1,000 real address strings from 125 countries are kept as a snapshot, one row per address (&lt;a href="https://github.com/Mokto/opengeocoding/blob/e9e8ed6dc73dd096bc28f801152c580c19f300ce/docs/evaluation/README.md#address-snapshots" rel="noopener noreferrer"&gt;address snapshots&lt;/a&gt;). They have no expected answers, so they don’t measure accuracy, but a changed answer shows up as one changed line in the pull request’s diff. And every night at 01:00 UTC, the same suite runs on the newest commit of &lt;code&gt;main&lt;/code&gt; and updates one issue with pass rates by country and a 14-night history.&lt;/p&gt;

&lt;h2&gt;
  
  
  A worked example
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://github.com/Mokto/opengeocoding/pull/223" rel="noopener noreferrer"&gt;Pull request #223&lt;/a&gt; set out to fix Australian addresses written with the street before the number, such as “Panorama Place 3, Maleny”. The engine was matching “Maleny” to house 3 on Maleny Vista, a street that merely resembles the town’s name.&lt;/p&gt;

&lt;p&gt;The first version ran one rule, for venues, before another, for village names. That reorder also fixed cases in Norway, Great Britain, Poland, South Africa and Spain. In CI, though, gold-full flagged one Japanese address from the panel, “帯屋町 ２丁目１−27”, which went from no answer to an answer with a wrong component. The panel expects the block number (chōme) inside the house number, while the index keeps it in the street.&lt;/p&gt;

&lt;p&gt;The second commit put the village rule back first and kept the narrower fix. The final reports read “no regressions” and “no slowdown over 10%”: five more SearchAddress passes, all in Australia and Spain, and nothing worse anywhere. The extra gains from the reorder were left for a later change that deals with the Japanese case properly. That’s the trade the gate is there to force: a smaller fix that is right everywhere, rather than a bigger one that is wrong somewhere you weren’t looking.&lt;/p&gt;

&lt;h2&gt;
  
  
  The agent loop
&lt;/h2&gt;

&lt;p&gt;The last piece connects the agent to these reports. The engine repository ships a project-local extension for pi, the coding agent I use, with one tool and one command (&lt;a href="https://github.com/Mokto/opengeocoding/blob/e9e8ed6dc73dd096bc28f801152c580c19f300ce/docs/evaluation/README.md#project-local-pi-gold-loop" rel="noopener noreferrer"&gt;gold loop&lt;/a&gt;). &lt;code&gt;wait_for_gold&lt;/code&gt; waits for both gates on the pull request’s current head and downloads the full report. &lt;code&gt;/gold-loop&lt;/code&gt; tells the agent what to do with it: read every section, reproduce each regression on the base and head indexes, fix the cause, validate locally, push, and wait again, until both quality and speed pass.&lt;/p&gt;

&lt;p&gt;Most of &lt;a href="https://github.com/Mokto/opengeocoding/blob/e9e8ed6dc73dd096bc28f801152c580c19f300ce/.pi/extensions/gold-wait/gold.ts" rel="noopener noreferrer"&gt;the instructions&lt;/a&gt; are about what doesn’t count as success. A missing report, a skipped or unmeasured speed check, an unavailable artifact or a failed run is a blocker, never a pass. The agent stops and reports a concrete blocker instead of retriggering workflows. The report’s contents are treated as data, never as instructions. The tool itself doesn’t edit code, push or merge; merging stays with me.&lt;/p&gt;

&lt;h2&gt;
  
  
  The speed gate is still noisy
&lt;/h2&gt;

&lt;p&gt;Quality is deterministic: the same binary on the same index gives the same answers. Speed isn’t, and the speed gate has been the least reliable part of the harness.&lt;/p&gt;

&lt;p&gt;It started as an ungated table inside the quality report. A change that made SearchAddress’s p50 15% slower went through without anyone noticing, which is why &lt;a href="https://github.com/Mokto/opengeocoding/pull/73" rel="noopener noreferrer"&gt;#73&lt;/a&gt; gave speed its own comment, status and 10% limit. That limit came from &lt;a href="https://github.com/Mokto/opengeocoding/pull/70" rel="noopener noreferrer"&gt;a measurement&lt;/a&gt; of three runs of one binary on a cluster node: totals agreed within 2% and each percentile within 5%, while single cases varied by about 30%. So single cases and the maximum are reported but not gated. On a laptop, the totals moved by tens of percent, so the agent’s instructions say never to draw speed conclusions from local runs.&lt;/p&gt;

&lt;p&gt;Two later changes moved the measurement:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;To keep the gate fast, &lt;a href="https://github.com/Mokto/opengeocoding/pull/217" rel="noopener noreferrer"&gt;#217&lt;/a&gt; times a fixed sample of 2,000 cases, chosen by a hash of the case id so both sides time the same ones, and checks the rest concurrently without timing them. The case loop went from 222 to 34 seconds locally. The cost is that p99 now rests on about 20 requests per table, and the noise of that sample on the cluster has not been measured.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://github.com/Mokto/opengeocoding/pull/319" rel="noopener noreferrer"&gt;#319&lt;/a&gt; makes the evaluator wait for the suggestion-cache warm-up before timing. The warm-up took 6.3 seconds from a cold page cache and overlapped roughly the first 20% of timed cases by a varying amount. Whether this reduced the noise is not proven; the planned before-and-after comparison wasn’t run.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The false alarms are still visible in the pull requests. In &lt;a href="https://github.com/Mokto/opengeocoding/pull/262" rel="noopener noreferrer"&gt;#262&lt;/a&gt;, SuggestAddress’s p99 was 18% slower although the change didn’t touch suggestions. &lt;a href="https://github.com/Mokto/opengeocoding/pull/336" rel="noopener noreferrer"&gt;#336&lt;/a&gt; merged with a failing speed check, SuggestAddress p99 29% slower, and in the follow-up &lt;a href="https://github.com/Mokto/opengeocoding/pull/339" rel="noopener noreferrer"&gt;#339&lt;/a&gt; part of that looked like noise, because ReverseGeocode, which the change didn’t touch, was 13% slower too. Part of the reason is scale. In the report for #223, SuggestAddress’s p99 was 4.5 ms, so the 10% limit is less than half a millisecond.&lt;/p&gt;

&lt;p&gt;This matters more with an agent in the loop than without one. A person who sees an 18% slowdown on code they didn’t touch can shrug and rerun. An agent told to keep going until speed passes has no good way to tell noise from a regression, and a flaky gate either sends it after a problem that isn’t there or teaches everyone to ignore the gate. The noise of the 2,000-case sample on the cluster is the open measurement, and until it is taken, the 10% limit rests on the older one.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the harness doesn’t do
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;It measures change, not accuracy.&lt;/strong&gt; A case that fails the same way on both sides doesn’t fail the gate. Some registries are also inputs to the index, so for those countries a pass is a fidelity check rather than an independent accuracy measurement.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Small numbers stay small.&lt;/strong&gt; Per-country rows are individual cases, not rates, and a rate computed from a few hundred cases carries several points of uncertainty.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The report has no per-case responses.&lt;/strong&gt; The cluster returns only the report, so debugging a listed case means rerunning it locally on an index from the same builder.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Outside contributions need approval.&lt;/strong&gt; Pull requests from people outside the project wait for a reviewer, because approving one runs that author’s code in the cluster.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of this makes the agent smarter. It makes the agent’s work checkable, quickly and against the whole world, and that is what lets me merge it.&lt;/p&gt;

&lt;p&gt;The engine, its evaluation tools and the gold loop are on GitHub: &lt;a href="https://github.com/Mokto/opengeocoding" rel="noopener noreferrer"&gt;Mokto/opengeocoding&lt;/a&gt;. The evaluation guide and &lt;code&gt;AGENTS.md&lt;/code&gt; are the best places to start if you want to build something similar for your own project.&lt;/p&gt;

</description>
      <category>geocoding</category>
      <category>rust</category>
      <category>programming</category>
    </item>
    <item>
      <title>Building a geocoder whose world index rebuilds in minutes</title>
      <dc:creator>Théo Mathieu</dc:creator>
      <pubDate>Sat, 14 Nov 2026 19:48:45 +0000</pubDate>
      <link>https://dev.to/mokto/building-a-geocoder-whose-world-index-rebuilds-in-minutes-2h35</link>
      <guid>https://dev.to/mokto/building-a-geocoder-whose-world-index-rebuilds-in-minutes-2h35</guid>
      <description>&lt;p&gt;I run OpenGeocoding. Originally published on &lt;a href="https://opengeocoding.com/blog/building-opengeocoding" rel="noopener noreferrer"&gt;the OpenGeocoding blog&lt;/a&gt;. Cross-post formatting was prepared with AI assistance.&lt;/p&gt;

&lt;p&gt;I build and run OpenGeocoding on my own: a geocoding engine written in Rust, and a hosted API in front of it. This post covers why I built it, how it works, how I measure it and where it is still weak.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Updated 5 November 2026: the benchmark and coverage figures now measure release 1.7.0.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The problem: an index you can’t rebuild
&lt;/h2&gt;

&lt;p&gt;Geocoding at scale means turning a lot of messy text into coordinates. Take a team that receives a few million company addresses, job locations and profile locations a month, in dozens of languages and formats, and needs a country, a region and, where the data allows, a house for each one. The engine’s unscored test sample is 1,000 real strings of that kind.&lt;/p&gt;

&lt;p&gt;I first tried to build on Nominatim, the geocoder behind openstreetmap.org. It is good software, but its build time was the problem. Its &lt;a href="https://nominatim.org/release-docs/latest/admin/Installation/" rel="noopener noreferrer"&gt;installation guide&lt;/a&gt; strongly recommends 128 GB of RAM or more for a planet import, requires at least 1 TB of disk, and describes the import as taking around 2.5 days on a well-configured machine.&lt;/p&gt;

&lt;p&gt;When a rebuild takes days, you don’t rebuild often. Data updates wait. It also means you can’t test a change to parsing or ranking against the whole world, because the thing you would test against takes days to produce. I wanted an index I could rebuild whenever the code or the data changed.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I built
&lt;/h2&gt;

&lt;p&gt;OpenGeocoding is one Rust process. It parses an address with &lt;a href="https://github.com/openvenues/libpostal" rel="noopener noreferrer"&gt;libpostal&lt;/a&gt;, looks it up in a read-only index and answers. There is no database, and nothing is downloaded at runtime: the index and the libpostal models are mounted read-only.&lt;/p&gt;

&lt;p&gt;The index is built from &lt;a href="https://overturemaps.org/" rel="noopener noreferrer"&gt;Overture Maps&lt;/a&gt;, with OpenStreetMap, OpenAddresses and GeoNames as additional inputs. Places, streets and postcodes go into &lt;a href="https://github.com/quickwit-oss/tantivy" rel="noopener noreferrer"&gt;Tantivy&lt;/a&gt;, the Rust equivalent of Lucene; house numbers go into finite-state transducers beside it. Dropping the Nominatim base for a search-engine index was the most important decision in the project.&lt;/p&gt;

&lt;p&gt;The last recorded world build, on 17 October 2026, took 409 seconds on an 18-core Apple M5 Max with 64 GB of RAM, with a 32.2 GB peak memory footprint and a result of about 16 GB on disk. Source downloads are not included (&lt;a href="https://github.com/Mokto/opengeocoding/blob/587f8893f86ccd3b38d55a4350614101d5d5ed73/docs/performance/README.md" rel="noopener noreferrer"&gt;build record and methodology&lt;/a&gt;). This is not a like-for-like comparison with a Nominatim import: the inputs, the indexing work and the hardware all differ. What it changed is how I work. I rebuild the index whenever the builder or one of its source datasets changes.&lt;/p&gt;

&lt;h3&gt;
  
  
  Rules per country, not exceptions per address
&lt;/h3&gt;

&lt;p&gt;Every country writes addresses differently, and libpostal, a statistical parser, gets some of them wrong. The tempting fix is to patch the address that failed. I try never to do that, and to write the rule for the country instead.&lt;/p&gt;

&lt;p&gt;Postcodes are the simplest example. The engine knows the &lt;a href="https://github.com/Mokto/opengeocoding/blob/6766514f67b4c62317e21f236e5abfa4eee02e55/src/matching.rs#L1626" rel="noopener noreferrer"&gt;postcode shape of 21 countries&lt;/a&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"gb"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="s2"&gt;"^[A-Z]{1,2}\d[A-Z\d]?(\d[A-Z]{2})?$"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"nl"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="s2"&gt;"^\d{4}([A-Z]{2})?$"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"ca"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="s2"&gt;"^[A-Z]\d[A-Z](\d[A-Z]\d)?$"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"pt"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="s2"&gt;"^\d{4}(\d{3})?$"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"us"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="s2"&gt;"^\d{5}(\d{4})?$"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"de"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="s2"&gt;"^\d{5}$"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The table is used to recover a postcode the parser labelled as something else, and to decide whether a postcode belongs to the country the query names. Other rules work the same way: German, Austrian and Swiss street names with a misspelt “strasse”, Portuguese streets under the national register’s abbreviated names, Japanese block addresses the engine reads itself because libpostal splits them, and Korean road-name addresses resolved through their province, city and district hierarchy.&lt;/p&gt;

&lt;p&gt;What keeps this honest is a gate. Every pull request that touches the engine runs the full test suite on a full world index, base against head. A case that newly fails, or fails in a worse way, fails the change, and expectations are never loosened to make it pass (&lt;a href="https://github.com/Mokto/opengeocoding/blob/6766514f67b4c62317e21f236e5abfa4eee02e55/docs/evaluation/README.md#gates" rel="noopener noreferrer"&gt;evaluation guide&lt;/a&gt;). A one-off patch for one address usually breaks another one somewhere else, and the gate shows it.&lt;/p&gt;

&lt;h3&gt;
  
  
  One process, in Rust
&lt;/h3&gt;

&lt;p&gt;An early version ran the index as a separate process and called it over HTTP. Merging the two into one process took one connection from about 220 to about 360 requests per second, and memory from 6.0 GB to 3.5 GB. Later work on how lookups use Tantivy cut CPU time per request from 8.2 ms to 1.4 ms, mostly by merging index segments at build time and by filtering document kinds in the collector instead of walking a term with 40 million postings. These were single measurements on a busy laptop, recorded in the &lt;a href="https://github.com/Mokto/opengeocoding/blob/6766514f67b4c62317e21f236e5abfa4eee02e55/docs/notes/lookup-history.md" rel="noopener noreferrer"&gt;lookup history&lt;/a&gt;, and they are not comparable with the benchmark below.&lt;/p&gt;

&lt;h3&gt;
  
  
  The hosted API is a separate service
&lt;/h3&gt;

&lt;p&gt;The geocoder stays stateless: no authentication, no database, no writes. Everything commercial lives in a separate service. A Rust edge in front of the geocoder holds API keys, per-second limits and credit balances in memory, so no request waits on Postgres. Every 5 seconds it flushes usage counts and reloads keys and balances. Each flush writes its batch number and the usage counts in one transaction, so a retried batch is never billed twice. If Postgres goes down, the edge keeps serving from its last snapshot. Only successful replies are billed.&lt;/p&gt;

&lt;h2&gt;
  
  
  How accuracy is measured
&lt;/h2&gt;

&lt;p&gt;The test corpus takes its expected answers from official registries and public reference sources: Wikidata, France’s Base Adresse Nationale, the Dutch BAG, the US Census geocoder, postcodes.io, Kartverket, swisstopo, CartoCiudad and other national address registries. Never from Nominatim, from another geocoder, or from this engine. A case passes when the first result is in the right country, within the case’s distance tolerance and at the right precision (house, street or town), and any house number or postcode it returns agrees with the request.&lt;/p&gt;

&lt;p&gt;I sent every provider the same queries, with no country or proximity hints, between 17 October and 5 November 2026, and scored release 1.7.0 against their answers (&lt;a href="https://github.com/Mokto/opengeocoding/blob/6812d06d7bfe9b96654b1fbc9dae90d77eb20c65/SCORECARD.md" rel="noopener noreferrer"&gt;scorecard and methodology&lt;/a&gt;):&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Service&lt;/th&gt;
&lt;th&gt;Full corpus (29,013 queries)&lt;/th&gt;
&lt;th&gt;US/Canada subset (1,947)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;OpenGeocoding&lt;/td&gt;
&lt;td&gt;85.6%&lt;/td&gt;
&lt;td&gt;97.7%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Geoapify&lt;/td&gt;
&lt;td&gt;70.5%&lt;/td&gt;
&lt;td&gt;81.0%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;OpenCage&lt;/td&gt;
&lt;td&gt;51.3%&lt;/td&gt;
&lt;td&gt;50.8%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;geocode.maps.co&lt;/td&gt;
&lt;td&gt;42.8%&lt;/td&gt;
&lt;td&gt;38.7%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Geocodio&lt;/td&gt;
&lt;td&gt;not measured&lt;/td&gt;
&lt;td&gt;89.3%&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;I ran this evaluation myself, on my own corpus, so read it with the caveats in the scorecard. Several national registries used as expectations (the US, the Netherlands, France, Norway, Switzerland, Spain, Czechia) are also inputs to the index, so in those countries a pass measures fidelity to the register, not independent accuracy. The corpus is not a random sample, and most of its cases were available while I developed the engine; on the fifth of locations held out from development, OpenGeocoding passed 86.4%. The OpenGeocoding row ran locally on an index from the release’s builder, close to but not identical to production. These are pass rates on a test corpus, not a production accuracy estimate.&lt;/p&gt;

&lt;h2&gt;
  
  
  Performance
&lt;/h2&gt;

&lt;p&gt;On the hosted API, the geocoder’s average processing time was 5 ms over about 87,864 completed requests in the 24 hours to 19 October 2026, 11:55 CEST. That excludes the edge, the network and response delivery (&lt;a href="https://opengeocoding.com/blog/billing-without-a-database-on-the-request-path#production-measurements" rel="noopener noreferrer"&gt;measurement details&lt;/a&gt;).&lt;/p&gt;

&lt;p&gt;For throughput, I benchmarked release 0.14.0 on 19 October 2026: one Kubernetes pod, 16 concurrent connections, three runs of 20,000 requests each. The runs reached 2,675, 2,656 and 1,774 queries per second, with p95 latency between 18.4 and 29.9 ms, and all 60,000 requests completed without errors. The pod requested one CPU with no limit and used 4.1 to 5.0 cores of a shared Intel Core i5-13500. The client cycled through 1,000 repeating queries with the parse cache warm, and called the pod directly, bypassing the ingress, TLS and the edge. It is a short warm-cache run, not a sustained capacity figure. The third run was slower than the first two; the &lt;a href="https://github.com/Mokto/opengeocoding/blob/587f8893f86ccd3b38d55a4350614101d5d5ed73/docs/performance/README.md" rel="noopener noreferrer"&gt;methodology and raw record&lt;/a&gt; has all three.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where it is weak
&lt;/h2&gt;

&lt;p&gt;House-level coverage across most of Asia and Africa is still poor. In the &lt;a href="https://github.com/Mokto/opengeocoding/blob/6812d06d7bfe9b96654b1fbc9dae90d77eb20c65/docs/evaluation/house-number-coverage-1.7.0.tsv" rel="noopener noreferrer"&gt;coverage measurement&lt;/a&gt; of the release 1.7.0 world index, India has 85,266 house numbers for 1.46 billion people, China 44,614, Indonesia 68,068, Pakistan 96,337, Nigeria 7,245 and Ethiopia 424. The United States has about 113 million, France 27 million, Japan 22 million and Taiwan 5.7 million. In the weak countries, expect a town or a street, not a house. The benchmark shows the same thing: of 151 street and house cases in Asian countries such as Malaysia, the Philippines, India, Vietnam and Thailand, OpenGeocoding passed 43, against 61 for Geoapify, and of 2,794 Korean school addresses, 795.&lt;/p&gt;

&lt;p&gt;The engine is strongest where open address data is dense: North America, most of Europe, Australia and New Zealand, and a few others such as Japan and Taiwan. Outside those, treat it as a town and region geocoder until you have checked your own addresses, and check your towns too: in the 78 countries and territories the corpus has no registry for, OpenGeocoding found 58.4% of 3,240 towns, behind Geoapify (68.4%) and OpenCage (66.4%), mostly because small settlements are missing from the index. The index has streets in 240+ countries and territories, but streets everywhere is not houses everywhere. Asia and Africa are what I’m working on next.&lt;/p&gt;

&lt;h2&gt;
  
  
  The licence
&lt;/h2&gt;

&lt;p&gt;The engine is source-available under &lt;a href="https://github.com/Mokto/opengeocoding/blob/main/LICENSE" rel="noopener noreferrer"&gt;PolyForm Shield 1.0.0&lt;/a&gt;, not open source. You can read it, run it, modify it and redistribute it, including commercially, and you can self-host it inside your own product. What you can’t do is offer a product that competes with it, for example by taking the engine and selling it as a geocoding API. A free competing service counts too. I picked it so the project can’t simply be taken and resold as someone else’s API.&lt;/p&gt;

&lt;p&gt;The data keeps its own licences: OpenStreetMap and parts of Overture are under the ODbL, and the &lt;a href="https://opengeocoding.com/legal/attributions" rel="noopener noreferrer"&gt;attributions page&lt;/a&gt; lists every source.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pricing
&lt;/h2&gt;

&lt;p&gt;The free plan has 50,000 requests a month at 2 requests per second, with no card. Paid plans start at $29 a month for 300,000 requests. Autocomplete is free, and when credits run out billable calls stop instead of creating an overage charge (&lt;a href="https://opengeocoding.com/pricing" rel="noopener noreferrer"&gt;pricing&lt;/a&gt;).&lt;/p&gt;

&lt;h2&gt;
  
  
  Try it
&lt;/h2&gt;

&lt;p&gt;The fastest way to judge it is the &lt;a href="https://opengeocoding.com/#demo" rel="noopener noreferrer"&gt;demo on the homepage&lt;/a&gt;: type an address from your own data, no account needed. The &lt;a href="https://opengeocoding.com/docs" rel="noopener noreferrer"&gt;API docs&lt;/a&gt; cover search, autocomplete, reverse geocoding and regions, and the engine is on &lt;a href="https://github.com/Mokto/opengeocoding" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt;. If it gets your country wrong, I want to hear about it.&lt;/p&gt;

</description>
      <category>geocoding</category>
      <category>rust</category>
      <category>programming</category>
    </item>
    <item>
      <title>Billing every API request exactly once, without a database on the request path</title>
      <dc:creator>Théo Mathieu</dc:creator>
      <pubDate>Sat, 14 Nov 2026 19:47:20 +0000</pubDate>
      <link>https://dev.to/mokto/billing-every-api-request-exactly-once-without-a-database-on-the-request-path-1ln8</link>
      <guid>https://dev.to/mokto/billing-every-api-request-exactly-once-without-a-database-on-the-request-path-1ln8</guid>
      <description>&lt;p&gt;I run OpenGeocoding. This article originally appeared on &lt;a href="https://opengeocoding.com/blog/billing-without-a-database-on-the-request-path" rel="noopener noreferrer"&gt;the OpenGeocoding blog&lt;/a&gt; on 20 October 2026. Cross-post formatting was prepared with AI assistance.&lt;/p&gt;

&lt;p&gt;The &lt;a href="https://opengeocoding.com/blog/building-opengeocoding" rel="noopener noreferrer"&gt;first post&lt;/a&gt; was about the geocoding engine. This one is about the smaller service in front of it: the edge that checks API keys, applies rate limits, spends credits and counts usage for billing. It is written in Rust like the engine, runs on Kubernetes as several identical replicas, and one rule shaped all of it: nothing on the request path waits for a database.&lt;/p&gt;

&lt;p&gt;The edge lives in a private repository, so I’ll describe it rather than link to it. The one code sample below is a simplified version of the real query.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why not write to the database on every request
&lt;/h2&gt;

&lt;p&gt;The obvious design is to look up the key and write a usage row in Postgres for every request. It’s simple and exact, and it’s what I considered first. The problem is throughput. In the engine’s benchmark, a single geocoder pod served 1,700+ searches a second (&lt;a href="https://github.com/Mokto/opengeocoding/blob/587f8893f86ccd3b38d55a4350614101d5d5ed73/docs/performance/README.md" rel="noopener noreferrer"&gt;methodology&lt;/a&gt;). If every one of those requests also writes to Postgres, the database has to sustain the same rate, for all customers at once, and keep sustaining it as the geocoder scales out. The engine would only be as fast as the billing database behind it.&lt;/p&gt;

&lt;p&gt;Latency points the same way. The geocoder’s average processing time is 5 ms (&lt;a href="https://opengeocoding.com/blog/billing-without-a-database-on-the-request-path#production-measurements" rel="noopener noreferrer"&gt;how it’s measured&lt;/a&gt;). A database round trip on every call would add its own latency on top, and a slow or unavailable database would become a slow or unavailable API.&lt;/p&gt;

&lt;h3&gt;
  
  
  How backend processing time was measured
&lt;/h3&gt;

&lt;p&gt;The request-weighted mean was 4.71 ms across approximately 87,864 completed RPC observations, from 18 October 2026, 11:55 CEST to 19 October 2026, 11:55 CEST, rounded to the nearest millisecond on the homepage. The mean divides the increase in summed gRPC handling time by the increase in completed RPC observations over that window.&lt;/p&gt;

&lt;p&gt;Over 99.9% were address searches, so this is not an all-endpoint benchmark. The timer excludes the public API edge, network time and response serialization/delivery. Public and internal callers are not distinguished. Rollouts and missed scrapes limit completeness; this is a dated observation, not a latency guarantee.&lt;/p&gt;

&lt;p&gt;So keys, limits and balances live in memory, and Postgres is updated in the background.&lt;/p&gt;

&lt;h2&gt;
  
  
  Kubernetes first
&lt;/h2&gt;

&lt;p&gt;Everything runs on Kubernetes, and I designed it that way from the start rather than moving it there later. Each part is a Deployment with several identical replicas spread across nodes, and none of them assumes it is the only copy:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The geocoder&lt;/strong&gt; is stateless and read-only. Each pod pulls a complete, immutable index from an S3-compatible store in an init container and serves it from a read-only volume. A small leader, one of two pods sharing a Kubernetes Lease, starts a build Job when the index named by the chart’s settings isn’t in the store yet, and rolls out the serving pods once it is. New pods load their index before old ones stop, so a rebuild never interrupts serving (&lt;a href="https://github.com/Mokto/opengeocoding/tree/680319656483dfccd3adbf5498bb4c2d108db1e0/manifests" rel="noopener noreferrer"&gt;engine Helm chart&lt;/a&gt;).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The edge&lt;/strong&gt;, the subject of this post, runs three replicas by default. Each has its own in-memory snapshot, its own counts and its own random ID. The replicas never talk to each other; they only meet in Postgres, every few seconds.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The web app&lt;/strong&gt; (marketing site, dashboard and signup) also runs three replicas.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;In front of the edge, an L7 ingress balances each HTTP request and gRPC call across pods, not each connection. Rollouts start a new pod before removing an old one, and disruption budgets keep node drains to one pod at a time.&lt;/p&gt;

&lt;p&gt;Most of what follows is about how the edge replicas agree on keys, limits and money without asking each other.&lt;/p&gt;

&lt;h2&gt;
  
  
  What a request does
&lt;/h2&gt;

&lt;p&gt;For each request, the edge:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;hashes the API key with SHA-256 and looks it up in an in-memory snapshot of active keys;&lt;/li&gt;
&lt;li&gt;takes a token from the account’s per-second bucket;&lt;/li&gt;
&lt;li&gt;for billable operations, checks the account’s remaining credits;&lt;/li&gt;
&lt;li&gt;calls the geocoder;&lt;/li&gt;
&lt;li&gt;counts the request if it succeeded.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;“Succeeded” is narrow on purpose. Only a JSON 2xx reply or a gRPC reply whose final &lt;code&gt;grpc-status&lt;/code&gt; is 0 is billed. Over gRPC, an HTTP 200 can still end with an error status in the trailers, so the edge buffers unary gRPC replies through their trailers before it counts anything. Backend failures return a generic error and aren’t billed. Autocomplete and region lookups need a key and share the rate limit, but they don’t spend credits.&lt;/p&gt;

&lt;p&gt;The remaining credits are worked out locally: the balance from the last Postgres load, minus the requests this replica has counted that the balance doesn’t include yet. When that reaches zero, billable calls get a 429. There is no metered overage.&lt;/p&gt;

&lt;p&gt;Each replica subtracts only its own counts. Requests served by the other replicas reach its balance through Postgres, after their next sync and its next reload. That gap of a few seconds is where the small overshoot described at the end comes from.&lt;/p&gt;

&lt;h2&gt;
  
  
  The 5-second sync
&lt;/h2&gt;

&lt;p&gt;Every 5 seconds, each edge replica runs one sync. It takes its pending counts as a batch, gives the batch the next sequence number and writes it in a single transaction:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;BEGIN&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="c1"&gt;-- Adds nothing if this batch was already written.&lt;/span&gt;
&lt;span class="k"&gt;INSERT&lt;/span&gt; &lt;span class="k"&gt;INTO&lt;/span&gt; &lt;span class="n"&gt;edge_flush&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;edge_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;seq&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;VALUES&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$2&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="k"&gt;CONFLICT&lt;/span&gt; &lt;span class="k"&gt;DO&lt;/span&gt; &lt;span class="k"&gt;NOTHING&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;-- Only when that insert added a row:&lt;/span&gt;
&lt;span class="k"&gt;INSERT&lt;/span&gt; &lt;span class="k"&gt;INTO&lt;/span&gt; &lt;span class="n"&gt;usage&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;key_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;day&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;count&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="nb"&gt;UNNEST&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$3&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="kt"&gt;uuid&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$4&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="kt"&gt;date&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$5&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="kt"&gt;bigint&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="k"&gt;CONFLICT&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;key_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;day&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;DO&lt;/span&gt; &lt;span class="k"&gt;UPDATE&lt;/span&gt; &lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="nb"&gt;count&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;usage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nb"&gt;count&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="no"&gt;EXCLUDED&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nb"&gt;count&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;-- Lock the accounts' credit grants, then spend the batch&lt;/span&gt;
&lt;span class="c1"&gt;-- from the grant that expires soonest.&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;credit_grant&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;user_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;ANY&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$6&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="k"&gt;FOR&lt;/span&gt; &lt;span class="k"&gt;UPDATE&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;UPDATE&lt;/span&gt; &lt;span class="n"&gt;credit_grant&lt;/span&gt; &lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="n"&gt;used&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;used&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;COMMIT&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;edge_flush&lt;/code&gt; row is what makes it exactly once. &lt;code&gt;edge_id&lt;/code&gt; is a random ID each edge process picks when it starts, and &lt;code&gt;seq&lt;/code&gt; numbers its batches, so replicas flush independently and their batches never collide. If the transaction fails, or commits but the reply never arrives, the batch stays in memory with the same sequence number and is sent again on the next sync. A retry of a batch that already committed hits the existing row and adds nothing; a retry of one that didn’t commit writes it for the first time. Either way, it lands once. A test against a real Postgres database flushes the same batch twice and checks the count. The row is written even when the batch is empty, so it also serves as the replica’s heartbeat.&lt;/p&gt;

&lt;p&gt;Credits are spent in the same transaction. Each paid period and each free month is a separate grant with its own expiry, and a batch is spent from the grant that expires soonest. Locking the account’s grant rows first means two replicas flushing at the same moment take turns instead of spending the same credits twice.&lt;/p&gt;

&lt;p&gt;After the commit, the sync reloads active keys, each account’s per-second limit and balance, and the number of edge replicas that flushed in the last 15 seconds. A batch stays counted against the local balance until a reload that includes it succeeds, so there is no moment where requests are neither in Postgres nor in memory.&lt;/p&gt;

&lt;p&gt;For Postgres, this means one write transaction per replica every 5 seconds, with rows per key and day rather than per request.&lt;/p&gt;

&lt;h2&gt;
  
  
  Rate limits without shared state
&lt;/h2&gt;

&lt;p&gt;Rate limits are the part I simplified most. A strict per-account limit across several replicas needs a shared counter, usually Redis, consulted on every request. Instead, each replica takes its share. With N replicas that sent a heartbeat in the last 15 seconds, an account’s bucket on each replica refills at limit / N tokens a second and holds at most the larger of limit / N and 2 tokens.&lt;/p&gt;

&lt;p&gt;As an example, take the free plan’s 2 requests a second across 5 replicas. Each replica refills at 0.4 a second and holds 2 tokens. Starting from full buckets, a client can make up to 10 requests, and then the refill settles at 2 a second overall. The two-token minimum absorbs short bursts when the load balancer happens to send a few requests to the same replica. &lt;code&gt;Retry-After&lt;/code&gt; is rounded up to the next local token, so an empty bucket in that example answers with 3 seconds rather than a fixed 1.&lt;/p&gt;

&lt;p&gt;Scaling needs no configuration. A new replica’s first sync is also its first heartbeat, so the others count it at their next reload, within 5 seconds, and lower their share. When a replica stops, its last heartbeat ages out after 15 seconds and the others raise their share at their next reload. While that happens, the shares are briefly off, which is one of the inaccuracies listed below.&lt;/p&gt;

&lt;h2&gt;
  
  
  When Postgres is down
&lt;/h2&gt;

&lt;p&gt;If Postgres is unreachable, the edge keeps serving from its last snapshot and keeps counting in memory, and the counts are written once the database is back. I chose to use a snapshot of any age rather than turn away paying customers. Signup, dashboard changes and the website demo do stop, because they need the database. The edge’s liveness check doesn’t depend on Postgres, so an outage never makes Kubernetes restart pods that are holding cached keys and unflushed usage.&lt;/p&gt;

&lt;p&gt;A new edge pod can’t load keys without Postgres, so it stays not ready while the existing pods carry the traffic. A rollout during an outage stalls instead of replacing working pods with ones that can’t serve.&lt;/p&gt;

&lt;h2&gt;
  
  
  Shutting down without losing usage
&lt;/h2&gt;

&lt;p&gt;Rollouts are the other moment where counts could go missing. Kubernetes replaces edge pods one at a time, starting the new one before the old one stops. On SIGTERM, the edge drains in-flight requests for up to 30 seconds, stops its background sync, then freezes billing and flushes everything still pending, retrying for up to 15 seconds. A request that finishes after billing is frozen gets an “unavailable” error instead of a successful reply that would never be billed. If the final flush still fails, the edge logs the lost usage and exits with a nonzero status. These budgets, a 10-second preStop delay and the error reporter’s 2-second close timeout all fit in Kubernetes’ 60-second grace period.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this costs
&lt;/h2&gt;

&lt;p&gt;The design accepts a few known inaccuracies. I’d rather list them here than have someone find them later:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A replica that crashes loses up to one sync interval, 5 seconds, of counts. Those requests are never billed.&lt;/li&gt;
&lt;li&gt;A balance can be overspent by roughly one sync of traffic plus bucket bursts. At the Growth plan’s 25 requests a second, that is about 125 requests plus bursts.&lt;/li&gt;
&lt;li&gt;Rate limits are approximate. Burst capacity grows with the number of replicas, restarts and changes in the replica count can briefly allow extra bursts or throttle early, and uneven routing can return a 429 below the account’s nominal rate.&lt;/li&gt;
&lt;li&gt;A persistent connection pinned to one pod only gets that pod’s share of the limit, so the load balancer has to balance each HTTP request and gRPC call, not each connection.&lt;/li&gt;
&lt;li&gt;Successful replies are charged when they are prepared, before delivery. A client that disconnects while the reply is on its way is still billed for the work.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Most of these fall in the customer’s favour. Lost counts are never billed, and an overspent balance means a few free requests rather than an unexpected invoice, because there is no overage to bill.&lt;/p&gt;

&lt;p&gt;The improvement I’d like to make later is sticky routing per account. If the ingress sent every request from one account to the same edge replica, that replica would hold the account’s whole rate limit and see all of its unflushed usage. Limits and balance checks would then be exact, without Redis and without a network call on the request path. It isn’t built yet, and it brings its own trade-offs. The ingress only sees the API key, not the account, so an account with several keys needs a way to land on the same replica. A very busy account would load a single replica. And when replicas are added or removed, some accounts move to another replica and start there with a fresh bucket.&lt;/p&gt;

&lt;p&gt;Shared counters in Redis would also make the limits exact, but at the cost of a round trip on every request, and I’d rather not pay that. Keeping Postgres in the background also keeps another option open: because the edge only talks to Postgres in the background, a second region with its own edge and geocoder could use a distant database without slowing anyone down.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try it
&lt;/h2&gt;

&lt;p&gt;The free plan includes 50,000 requests a month at 2 requests a second, with no card. &lt;a href="https://opengeocoding.com/login?mode=signup" rel="noopener noreferrer"&gt;Create a free account&lt;/a&gt;, make a few calls, and you’ll see them in the dashboard’s usage, written by the sync described above.&lt;/p&gt;

</description>
      <category>architecture</category>
      <category>postgres</category>
      <category>rust</category>
      <category>kubernetes</category>
    </item>
  </channel>
</rss>
