<?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: OutSend</title>
    <description>The latest articles on DEV Community by OutSend (@outsendxyz).</description>
    <link>https://dev.to/outsendxyz</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%2F4033560%2F1681d315-8bde-4fe6-93fb-6e9331be97c8.png</url>
      <title>DEV Community: OutSend</title>
      <link>https://dev.to/outsendxyz</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/outsendxyz"/>
    <language>en</language>
    <item>
      <title>Batch Geocoding at Scale with France's Free BAN API: Thresholds, Chunks, and Zero Lock Contention</title>
      <dc:creator>OutSend</dc:creator>
      <pubDate>Mon, 27 Jul 2026 08:14:41 +0000</pubDate>
      <link>https://dev.to/outsendxyz/batch-geocoding-at-scale-with-frances-free-ban-api-thresholds-chunks-and-zero-lock-contention-42lo</link>
      <guid>https://dev.to/outsendxyz/batch-geocoding-at-scale-with-frances-free-ban-api-thresholds-chunks-and-zero-lock-contention-42lo</guid>
      <description>&lt;p&gt;We needed GPS coordinates for 14.4 million French business establishments to power radius-based searches. Here's the pipeline we built — free, no third-party services, and designed to run daily alongside a live application without ever locking the database.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Starting Point
&lt;/h2&gt;

&lt;p&gt;The French SIRENE registry lists every active business establishment in the country: 14,387,175 of them as of July 2026. When we needed to enable queries like "find all plumbers within 20 km of Bordeaux," we had a problem: SIRENE includes postal addresses but no GPS coordinates.&lt;/p&gt;

&lt;p&gt;INSEE (France's national statistics institute) publishes an official geocoded dataset — GeoSirene — but it's updated monthly. New establishments registered between releases have no coordinates. For any product that processes fresh SIRENE data daily, you need to geocode incrementally.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the BAN API
&lt;/h2&gt;

&lt;p&gt;France's &lt;strong&gt;Base Adresse Nationale&lt;/strong&gt; at &lt;code&gt;api-adresse.data.gouv.fr&lt;/code&gt; is purpose-built for this:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Free, no API key&lt;/strong&gt; — government infrastructure, openly accessible&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Bulk CSV endpoint&lt;/strong&gt; — submit thousands of rows at once, get coordinates and confidence scores back&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;FR-specific precision&lt;/strong&gt; — trained on the official national address database, not crowd-sourced data&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No ban on server requests&lt;/strong&gt; — unlike the INSEE search API, the BAN accepts requests from datacenter IPs&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;We evaluated Nominatim as an alternative. We ruled it out: their bulk usage policy limits automated queries, and their precision on small French establishments (artisans, micro-entrepreneurs registered at a home address) is lower than BAN.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two-Layer Architecture
&lt;/h2&gt;

&lt;p&gt;We separated the work into two distinct layers:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Monthly&lt;/strong&gt;: A full rebuild from the official GeoSirene dataset. INSEE releases a geocoded stock file each month — more precise than the API alone because it uses additional cross-referencing sources. This handles the bulk.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Daily&lt;/strong&gt;: An incremental pass that picks up new establishments from the SIRENE flux that have &lt;code&gt;lat IS NULL&lt;/code&gt;. This is where the BAN API comes in.&lt;/p&gt;

&lt;p&gt;The query that identifies what needs geocoding:&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="n"&gt;sql&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;SELECT id, adresse, code_postal, commune FROM etablissement &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;WHERE lat IS NULL AND adresse IS NOT NULL AND code_postal IS NOT NULL &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;AND code_postal GLOB &lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;[0-9][0-9][0-9][0-9][0-9]&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt; &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;AND code_postal NOT LIKE &lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;99%&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt; &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;AND code_postal NOT LIKE &lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;987%&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt; AND code_postal NOT LIKE &lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;988%&lt;/span&gt;&lt;span class="sh"&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 exclusion filters matter:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;99xxx&lt;/code&gt; codes are foreign addresses (legitimate in SIRENE for companies with foreign legal seats)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;987&lt;/code&gt;, &lt;code&gt;988&lt;/code&gt; are French Polynesia and New Caledonia — territories not covered by the BAN&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Batch Size and Politeness
&lt;/h2&gt;

&lt;p&gt;The BAN accepts CSV POSTs up to ~50 MB. We settled on &lt;strong&gt;8,000 rows per request&lt;/strong&gt; — well within the limit even with verbose addresses, and keeping individual response times manageable (30–90 seconds depending on server load).&lt;/p&gt;

&lt;p&gt;Between requests, we pause &lt;strong&gt;1 second&lt;/strong&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="n"&gt;CHUNK&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;8000&lt;/span&gt;
&lt;span class="n"&gt;PAUSE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;1.0&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The BAN is shared public infrastructure. Slamming it with no pause isn't respectful and risks throttling during peak hours.&lt;/p&gt;

&lt;h2&gt;
  
  
  Score Threshold
&lt;/h2&gt;

&lt;p&gt;The API returns a &lt;code&gt;result_score&lt;/code&gt; between 0 and 1. We reject anything below &lt;code&gt;0.4&lt;/code&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="n"&gt;MIN_SCORE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;0.4&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In practice, real French business addresses with a valid postal code and commune rarely fall below 0.4 — the BAN finds them. Scores below this threshold typically indicate:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Non-standard industrial zones or new subdivisions not yet in the address database&lt;/li&gt;
&lt;li&gt;CEDEX routing codes without a physical street address&lt;/li&gt;
&lt;li&gt;Data-entry errors in the source (address and commune from different cities)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;We do not try to force a geocode for low-confidence records. A wrong coordinate is worse than no coordinate when you're doing radius queries: a business that shows up in the wrong city will appear in searches it shouldn't, and disappear from searches it should be in.&lt;/p&gt;

&lt;h2&gt;
  
  
  Retry Logic
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;attempt&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;range&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;raw&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;urllib&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;urlopen&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;req&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;timeout&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;180&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;read&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="k"&gt;break&lt;/span&gt;
    &lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="nb"&gt;Exception&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;ex&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;attempt&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;batch KO (&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;ex&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;) → skipped&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sleep&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;attempt&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Five attempts, with 3/6/9/12-second backoff. The 180-second timeout is intentionally generous — large batches on a loaded BAN server take longer than you'd expect.&lt;/p&gt;

&lt;h2&gt;
  
  
  WAL Mode: Zero Lock Contention
&lt;/h2&gt;

&lt;p&gt;The geocoding script runs at 04:30 daily, while the application is serving user requests. Without care, each commit would grab an exclusive write lock and stall every concurrent reader.&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="n"&gt;conn&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;PRAGMA journal_mode=WAL&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;conn&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;PRAGMA busy_timeout=60000&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;WAL (Write-Ahead Logging) lets readers continue against the last committed snapshot while the writer appends to the WAL file. Readers never wait for the geocoder. The &lt;code&gt;busy_timeout&lt;/code&gt; gives any competing writer 60 seconds before returning an error — in practice this never fires.&lt;/p&gt;

&lt;p&gt;This matters more than it sounds. The &lt;code&gt;etablissement&lt;/code&gt; table holds 14.4 million rows. Even with targeted &lt;code&gt;WHERE lat IS NULL&lt;/code&gt; updates, a batch commit touches thousands of rows. In default journal mode, that's thousands of readers paused per commit cycle.&lt;/p&gt;

&lt;h2&gt;
  
  
  R-Tree Index Updates
&lt;/h2&gt;

&lt;p&gt;Geocoding isn't just writing lat/lon. We also maintain a spatial R-tree index for fast radius queries:&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="n"&gt;conn&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;DELETE FROM etab_rtree WHERE id=?&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;eid&lt;/span&gt;&lt;span class="p"&gt;,))&lt;/span&gt;
&lt;span class="n"&gt;conn&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;INSERT INTO etab_rtree VALUES(?,?,?,?,?)&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
             &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;eid&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;lat&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;lat&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;lon&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;lon&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This happens atomically within the same transaction as the coordinate update, so the index never diverges from the data.&lt;/p&gt;

&lt;h2&gt;
  
  
  Results
&lt;/h2&gt;

&lt;p&gt;After the initial geocoding run over the full 14.387M-establishment database:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Overall GPS coverage: 98.4%&lt;/strong&gt; — 14,156,534 establishments with coordinates&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;France-only coverage: 99.9%&lt;/strong&gt; of eligible FR addresses (after applying the postal code filter)&lt;/li&gt;
&lt;li&gt;The BAN API returned a valid result above the 0.4 threshold for &lt;strong&gt;~93% of submitted French address records&lt;/strong&gt; in our production monitoring — the remaining ~7% are genuinely hard cases the API cannot resolve confidently&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The ~12,000 records still without coordinates are the true hard cases: incomplete addresses, non-standard zones, or data-entry errors in the source. They stay at &lt;code&gt;lat IS NULL&lt;/code&gt; rather than receiving a wrong coordinate.&lt;/p&gt;

&lt;h2&gt;
  
  
  Integration
&lt;/h2&gt;

&lt;p&gt;The geocoder chains directly after the daily SIRENE flux import:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# cron 04:30 daily, as the application user&lt;/span&gt;
flux_daily.py &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; geocode.py
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Both scripts run against the same SQLite database the live application reads, using WAL mode throughout. This pipeline is part of what powers the map search in &lt;a href="https://outsend.xyz" rel="noopener noreferrer"&gt;OutSend&lt;/a&gt;, letting users filter 14 million business records by radius at query time — with no external geocoding service involved.&lt;/p&gt;

&lt;h2&gt;
  
  
  What We'd Do Differently
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Normalize addresses before submitting.&lt;/strong&gt; Stripping CEDEX suffixes, collapsing double spaces, and removing newlines from address fields before submission would likely recover a few percentage points of the 7% failure rate.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Track score distribution, not just totals.&lt;/strong&gt; We log how many records were geocoded but not the histogram of scores. Knowing whether failures cluster at 0.3–0.4 (recoverable with better normalization) versus 0.0–0.1 (genuinely unparseable) would help prioritize address cleanup.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Commune-level fallback for hard cases.&lt;/strong&gt; For the ~12,000 records that remain unresolved, a centroid at the commune level would be better than nothing for approximate radius queries. We haven't needed it yet.&lt;/p&gt;

</description>
      <category>python</category>
      <category>sqlite</category>
      <category>tutorial</category>
      <category>opensource</category>
    </item>
    <item>
      <title>Reconcile before you round: lessons from a 14M-row open data study</title>
      <dc:creator>OutSend</dc:creator>
      <pubDate>Fri, 24 Jul 2026 08:52:11 +0000</pubDate>
      <link>https://dev.to/outsendxyz/reconcile-before-you-round-lessons-from-a-14m-row-open-data-study-12b1</link>
      <guid>https://dev.to/outsendxyz/reconcile-before-you-round-lessons-from-a-14m-row-open-data-study-12b1</guid>
      <description>&lt;p&gt;We recently published a data study on the French business registry — 14.4 million active establishments, sliced by workforce, sector and geography. The numbers themselves aren't the interesting part for this post. What is: the rules we forced ourselves to follow so that anyone re-running the same queries on the same open dataset would get the &lt;em&gt;exact same numbers&lt;/em&gt;, and so every headline percentage could be traced back to a total someone else can check.&lt;/p&gt;

&lt;p&gt;This is a walkthrough of that process, using &lt;a href="https://www.insee.fr/fr/information/1896277" rel="noopener noreferrer"&gt;INSEE's SIRENE registry&lt;/a&gt; (France's official open-data business registry) as the example dataset, because it's public, it's messy in the ways real government data tends to be, and the mistakes we ran into are the boring, common kind — not exotic edge cases.&lt;/p&gt;

&lt;h2&gt;
  
  
  Reconcile before you round
&lt;/h2&gt;

&lt;p&gt;The registry snapshot we worked from (dated July 1, 2026) contains 14,387,175 active, publishable establishments and 26,756,173 legal units in total, of which 14,470,762 are active. Those three numbers are the anchor for everything else: before computing a single percentage, we re-derived each of them independently from the raw stock and diffed against the reference totals published alongside the dataset. Zero-diff or the query is wrong — not the data.&lt;/p&gt;

&lt;p&gt;That discipline caught a real bug early on: our first pass at "establishments with no declared workforce" summed the wrong INSEE status codes and landed a few points off. The correct answer splits into two codes — &lt;code&gt;NN&lt;/code&gt; (workforce not reported, 12,486,437 establishments) and &lt;code&gt;00&lt;/code&gt; (zero salaried staff, 71,325) — for a combined 12,557,762, or 87.28% of the active stock. Small distinction, but it changes the number, and it only surfaces if you reconcile against a known total instead of trusting the first query that runs without an error.&lt;/p&gt;

&lt;h2&gt;
  
  
  Document exclusions, don't quietly drop them
&lt;/h2&gt;

&lt;p&gt;Any geographic breakdown of this dataset means deriving a département (the French equivalent of a county) from a postal code — and postal codes in a 14-million-row open dataset are not clean. Corsica is aggregated under a synthetic "20" code, overseas départships need 3-character handling, and there's a long tail of malformed or foreign entries.&lt;/p&gt;

&lt;p&gt;Rather than silently filtering rows that didn't fit and reporting a tidy total, we counted and published every excluded bucket: 157,187 establishments with no postal code at all, 27,525 under code "99" (foreign addresses), 5,107 under the "98x" range (Monaco, French Polynesia), 1,290 in Saint-Pierre-et-Miquelon, and 4,574 residual malformed codes (stray formats like partial UK postcodes that clearly don't belong in a French address field). None of this moves the order of magnitude of the geographic ranking — Paris still dominates with 1,159,693 establishments — but it's the difference between a defensible percentage and one that quietly excludes 200k rows without saying so.&lt;/p&gt;

&lt;h2&gt;
  
  
  Treat a surprising ranking as a prompt to investigate, not a headline
&lt;/h2&gt;

&lt;p&gt;Rank establishments by sector and the top result is "real estate activities" — 3,326,963 establishments, 23.1% of the entire stock. That's not a booming property market; it's an artifact. Sociétés civiles immobilières (SCIs), a common French legal wrapper used purely to hold or transfer property, register as establishments like any operating business, despite rarely being a sales or outreach target for anyone. A naive "top sectors" chart would have reported this uncritically. Flagging it meant going back to first principles for every ranking in the study: is this number describing economic activity, or a legal-structure artifact that happens to be countable?&lt;/p&gt;

&lt;h2&gt;
  
  
  Sanity-check date boundaries
&lt;/h2&gt;

&lt;p&gt;The "new registrations in 2026" count needed a lower bound (start of year) and, less obviously, an upper one. Filtering the raw creation-date field turned up 102 records dated in 2027 or later — clearly bad input, not a preview of the future. They were excluded after inspecting the bound, and the exclusion was logged rather than silently absorbed into the total.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this adds up to
&lt;/h2&gt;

&lt;p&gt;None of these four steps are novel techniques. They're closer to a checklist: reconcile every aggregate against an independent total before publishing it, count and disclose what you exclude instead of hiding it in a WHERE clause, question rankings that look too clean, and check your date boundaries before you trust a "recent activity" number. The interesting part isn't any single check — it's that skipping any one of them produces a plausible-looking, wrong number, and open government datasets this large will always have enough edge cases to hide one.&lt;/p&gt;

&lt;p&gt;We do this kind of data engineering as part of building &lt;a href="https://outsend.xyz" rel="noopener noreferrer"&gt;OutSend&lt;/a&gt;, a lead-generation platform — the reconciliation habits above came directly from having to trust the same pipelines in production, not just for a one-off report.&lt;/p&gt;

</description>
      <category>data</category>
      <category>sqlite</category>
      <category>opensource</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>Verifying B2B emails without sending a single one: how MX-only validation actually works</title>
      <dc:creator>OutSend</dc:creator>
      <pubDate>Tue, 21 Jul 2026 12:08:08 +0000</pubDate>
      <link>https://dev.to/outsendxyz/verifying-b2b-emails-without-sending-a-single-one-how-mx-only-validation-actually-works-3pnc</link>
      <guid>https://dev.to/outsendxyz/verifying-b2b-emails-without-sending-a-single-one-how-mx-only-validation-actually-works-3pnc</guid>
      <description>&lt;p&gt;When you're building or using a B2B prospecting tool, "verified email" usually means one of two things: a real SMTP handshake (connect, &lt;code&gt;MAIL FROM&lt;/code&gt;, &lt;code&gt;RCPT TO&lt;/code&gt;, read the response, disconnect without sending) or a lighter DNS-only check. Most articles about email verification gloss over &lt;em&gt;why&lt;/em&gt; you'd pick one over the other. This post walks through the mechanics, the tradeoffs, and a real-world constraint that forces the decision for a lot of small/medium senders: &lt;strong&gt;outbound port 25 being blocked.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The three layers of "is this email real"
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Syntax check&lt;/strong&gt; — regex/RFC 5322 parsing. Catches typos (&lt;code&gt;jhon@gmial.com&lt;/code&gt;), catches obviously malformed input. Cheap, instant, but tells you almost nothing about deliverability.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;DNS / MX check&lt;/strong&gt; — does the domain have valid MX records? Does it resolve? This confirms the &lt;em&gt;domain&lt;/em&gt; can receive mail, not that the &lt;em&gt;mailbox&lt;/em&gt; exists.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;SMTP handshake (no send)&lt;/strong&gt; — connect to the MX host on port 25, issue &lt;code&gt;RCPT TO:&amp;lt;address&amp;gt;&lt;/code&gt;, read the server's response code (250 = accepted, 550 = mailbox doesn't exist, etc.), then &lt;code&gt;QUIT&lt;/code&gt; before &lt;code&gt;DATA&lt;/code&gt;. This is the only layer that actually confirms mailbox existence — and it's also the layer most likely to break in production.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Why the SMTP handshake breaks for so many senders
&lt;/h2&gt;

&lt;p&gt;Outbound TCP port 25 is blocked by default on most consumer ISPs and a growing number of cloud/VPS providers (it's one of the oldest anti-spam measures on the internet — stops compromised machines from becoming open relays). If your verification worker runs on a box where port 25 egress is blocked, every &lt;code&gt;RCPT TO&lt;/code&gt; attempt just times out. You get zero signal, not a "550 doesn't exist" — just silence, indistinguishable from a slow mail server.&lt;/p&gt;

&lt;p&gt;Three ways to deal with this:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Route SMTP checks through infrastructure with confirmed port 25 egress&lt;/strong&gt; (a subset of your VPS fleet, or a dedicated relay you control). Works, but adds an ops dependency you now have to monitor.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pay for a third-party verification API&lt;/strong&gt; that already solved this problem. Fine at low volume, expensive fast, and you're trusting an external service with your prospect list.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Fall back to MX-only + heuristics&lt;/strong&gt; when port 25 is unavailable, and be honest about the confidence level of the result instead of pretending it's a full SMTP verification.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;We went with the third option for our own scraper/enrichment pipeline: when port 25 egress isn't available on a given worker, the check degrades gracefully to "domain has valid MX, catch-all detection where possible, syntax valid" rather than silently returning a false "verified." The label shown to the end user reflects which layer actually ran — "MX confirmed" is not the same claim as "mailbox confirmed," and conflating the two is how verification tools end up with inflated "95% deliverable" numbers that don't survive contact with a real campaign.&lt;/p&gt;

&lt;h2&gt;
  
  
  Catch-all domains are the other trap
&lt;/h2&gt;

&lt;p&gt;Even with full SMTP access, a lot of corporate mail servers respond &lt;code&gt;250 OK&lt;/code&gt; to &lt;em&gt;any&lt;/em&gt; &lt;code&gt;RCPT TO&lt;/code&gt; on their domain (catch-all configuration) — meaning a successful handshake doesn't actually prove the specific mailbox exists. Standard mitigation: probe a random, almost-certainly-nonexistent local part (&lt;code&gt;asdkjh1928@domain.com&lt;/code&gt;) on the same domain. If that also comes back 250, the domain is catch-all and your original address's "verified" status should be downgraded to "plausible, unconfirmed" rather than reported as a clean hit.&lt;/p&gt;

&lt;h2&gt;
  
  
  The practical takeaway
&lt;/h2&gt;

&lt;p&gt;If you're evaluating (or building) an email verification step in a prospecting pipeline, ask two questions before trusting a "valid" badge:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Did this go through an actual SMTP handshake, or just an MX/DNS check? (Both are useful — they are not the same guarantee.)&lt;/li&gt;
&lt;li&gt;Was catch-all detection run, and how does the tool label a catch-all hit vs. a genuinely confirmed mailbox?&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Most vendors don't surface this distinction because "98% verified" sells better than "72% SMTP-confirmed, 26% MX-only (port 25 blocked on this worker), catch-all flagged separately." We'd rather ship the second, less flattering number, because it's the one that actually predicts bounce rate.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;We build &lt;a href="https://outsend.xyz" rel="noopener noreferrer"&gt;OutSend&lt;/a&gt;, an all-in-one B2B lead generation platform — Google Maps scraping, email enrichment, deliverability verification and no-code pipelines chained end-to-end. Free during alpha, no credit card required. This post describes real constraints we hit building the verification layer, not a product pitch — happy to go deeper on any of it in the comments.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>email</category>
      <category>dns</category>
      <category>backend</category>
    </item>
    <item>
      <title>OutSend: an all-in-one B2B lead generation tool (Google Maps scraping, email finder, deliverability check)</title>
      <dc:creator>OutSend</dc:creator>
      <pubDate>Fri, 17 Jul 2026 09:51:20 +0000</pubDate>
      <link>https://dev.to/outsendxyz/outsend-an-all-in-one-b2b-lead-generation-tool-google-maps-scraping-email-finder-deliverability-4m6a</link>
      <guid>https://dev.to/outsendxyz/outsend-an-all-in-one-b2b-lead-generation-tool-google-maps-scraping-email-finder-deliverability-4m6a</guid>
      <description>&lt;p&gt;We built OutSend because prospecting for B2B leads usually means stitching together 3-4 different tools: a Google Maps scraper, an email finder, an email verifier, and a spreadsheet to tie it all together.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it does
&lt;/h2&gt;

&lt;p&gt;OutSend is an all-in-one B2B lead generation platform:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Scrape&lt;/strong&gt; business leads from Google Maps by city, category and keyword&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Enrich&lt;/strong&gt; them with emails (email finder)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Verify&lt;/strong&gt; deliverability before you send anything (anti-bounce check)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Chain&lt;/strong&gt; scraping, enrichment and validation end-to-end with no-code pipelines&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Export&lt;/strong&gt; clean CSV files in seconds&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Stack
&lt;/h2&gt;

&lt;p&gt;FastAPI + SQLite on the backend, Alpine.js on the frontend, self-hosted (no paid third-party SaaS dependency in the core pipeline).&lt;/p&gt;

&lt;h2&gt;
  
  
  Who it's for
&lt;/h2&gt;

&lt;p&gt;Sales teams, agencies and founders who prospect at scale and are tired of paying for (and wiring up) four different subscriptions to do one job.&lt;/p&gt;

&lt;p&gt;It's free during the alpha, no credit card required: &lt;a href="https://outsend.xyz" rel="noopener noreferrer"&gt;https://outsend.xyz&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Happy to answer any questions about the scraping/enrichment pipeline in the comments.&lt;/p&gt;

</description>
      <category>showdev</category>
      <category>saas</category>
      <category>productivity</category>
      <category>startup</category>
    </item>
  </channel>
</rss>
