<?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: Jefferson Valandro</title>
    <description>The latest articles on DEV Community by Jefferson Valandro (@jeffev).</description>
    <link>https://dev.to/jeffev</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%2F4158694%2F98f15f5d-1493-420a-a8bd-6120bc473057.jpg</url>
      <title>DEV Community: Jefferson Valandro</title>
      <link>https://dev.to/jeffev</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/jeffev"/>
    <language>en</language>
    <item>
      <title>I turned Brazil's 73M-establishment company registry into second-fast lookups, with no server and no database</title>
      <dc:creator>Jefferson Valandro</dc:creator>
      <pubDate>Sat, 03 Oct 2026 00:36:17 +0000</pubDate>
      <link>https://dev.to/jeffev/i-turned-brazils-73m-establishment-company-registry-into-second-fast-lookups-with-no-server-and-2bge</link>
      <guid>https://dev.to/jeffev/i-turned-brazils-73m-establishment-company-registry-into-second-fast-lookups-with-no-server-and-2bge</guid>
      <description>&lt;p&gt;Brazil's tax authority (Receita Federal) publishes its entire company registry (CNPJ) as open data every month: every company, its address, phone, e-mail, industry code (CNAE), size, tax regime and partners. It's a goldmine for B2B sales, and it's painful to use: 37 zip files, ~8 GB compressed, ~27 GB of semicolon-separated text, and several traps along the way.&lt;/p&gt;

&lt;p&gt;I wanted answers like &lt;em&gt;"restaurants opened in the last 90 days in Campinas, with a phone number"&lt;/em&gt; or &lt;em&gt;"which company owns &lt;code&gt;acme.com.br&lt;/code&gt;?"&lt;/em&gt; in seconds, without paying for a server or a database. Here's the setup and, more usefully, everything that went wrong.&lt;/p&gt;

&lt;h2&gt;
  
  
  The architecture
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Receita zips ─► DuckDB (on disk) ─► Parquet ─► private Hugging Face dataset
                                                        │
                 Apify Actors (DuckDB / pyarrow) ◄──────┘
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;DuckDB&lt;/strong&gt; joins establishments, companies, the Simples/MEI table and lookup tables.&lt;/li&gt;
&lt;li&gt;The result becomes &lt;strong&gt;Parquet laid out four ways&lt;/strong&gt;, one per query shape: per state sorted by industry code (lead search), per CNPJ prefix (bulk lookup), per city (matching Google Maps places) and per e-mail domain.&lt;/li&gt;
&lt;li&gt;The files live in a &lt;strong&gt;private Hugging Face dataset&lt;/strong&gt;: free, no credit card, and it serves HTTP range requests.&lt;/li&gt;
&lt;li&gt;Each search runs as an &lt;strong&gt;Apify Actor&lt;/strong&gt; that reads only the row groups it needs. Because files are sorted, per-row-group min/max statistics tell you exactly where to look.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The traps
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;1. The files say ISO-8859-1. They aren't.&lt;/strong&gt; Some bytes only make sense in Windows-1252 and DuckDB's latin-1 reader rejects the file. I transcode during extraction: &lt;code&gt;chunk.decode("cp1252", errors="replace")&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. &lt;code&gt;ignore_errors=true&lt;/code&gt; silently dropped 74% of the companies.&lt;/strong&gt; Names like &lt;code&gt;BAR ""DO ZE"" LTDA&lt;/code&gt; use doubled quotes as escapes. With default settings DuckDB treated those lines as malformed and &lt;code&gt;ignore_errors&lt;/code&gt; skipped them without a word: my first build had 8M active establishments instead of 28M. The fix is &lt;code&gt;escape='"'&lt;/code&gt;; the real lesson is &lt;strong&gt;always compare parsed rows with the file's line count&lt;/strong&gt;. The converter now refuses to publish when they differ.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. 32 GB of RAM wasn't enough.&lt;/strong&gt; Joining 28M establishments with 70M companies and sorting got the process killed. A disk-backed DuckDB database, &lt;code&gt;memory_limit='6GB'&lt;/code&gt;, a temp directory and one state at a time fixed it. For the 73M-row lookup table, a single global sort hadn't finished after 20 minutes; partitioning by CNPJ prefix in one pass and sorting each ~700k-row piece took 15 minutes total.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. DuckDB over HTTP was slow for point lookups.&lt;/strong&gt; Every query re-validated the file and followed Hugging Face's redirect: ~2 s per CNPJ. Switching to &lt;code&gt;pyarrow&lt;/code&gt; + &lt;code&gt;HfFileSystem&lt;/code&gt; changed that: read each file's footer once, group the requested keys by the row group they fall in (min/max stats), fetch each row group once, 64 in parallel. Shrinking row groups from 20k to 2k rows cut the bytes downloaded for 2,000 scattered keys from ~3.2 GB to ~0.5 GB. Net result: &lt;strong&gt;209 s → 53 s for 2,000 lookups, at 1/6 of the cost.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;5. The registered e-mail is often the accountant's.&lt;/strong&gt; One accounting firm appears as the contact e-mail of 438 companies. To find a company by domain, the chosen name has to &lt;em&gt;resemble&lt;/em&gt; the domain (&lt;code&gt;corsi.com.br&lt;/code&gt; → CORSI CONTABILIDADE); names that only share the city ("... CAMPINAS") don't count.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;6. Matching Google Maps places to the registry.&lt;/strong&gt; Phone and ZIP + street number produce candidates; name similarity and industry compatibility decide (a dentist must not match the real-estate agency in the same building). On 180 real places in Campinas, 60% matched, almost all with high confidence.&lt;/p&gt;

&lt;h2&gt;
  
  
  Privacy (LGPD)
&lt;/h2&gt;

&lt;p&gt;It's public data, but: individual micro-entrepreneurs (MEI) are excluded from lead searches by default, partners come without their personal tax ID, and the personal ID that the registry appends to sole-proprietor names is stripped.&lt;/p&gt;

&lt;h2&gt;
  
  
  Keeping it fresh
&lt;/h2&gt;

&lt;p&gt;A Windows scheduled task checks for a new month every night. When one appears it downloads, converts, compares row counts with the previous month, uploads to Hugging Face, &lt;strong&gt;verifies every file actually arrived&lt;/strong&gt; (I once had an upload "succeed" without a whole folder), and runs a test against each Actor. Any failure becomes a desktop notification instead of a silently broken product.&lt;/p&gt;

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

&lt;p&gt;The four searches are public on Apify, pay per result:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://apify.com/jeffev/leads-empresas-brasil-cnpj" rel="noopener noreferrer"&gt;Brazilian company leads by industry and city&lt;/a&gt;, including companies opened in the last N days&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://apify.com/jeffev/consulta-cnpj-lote" rel="noopener noreferrer"&gt;Bulk CNPJ lookup&lt;/a&gt; with registration status, alphanumeric CNPJ supported&lt;/li&gt;
&lt;li&gt;&lt;a href="https://apify.com/jeffev/encontrar-cnpj" rel="noopener noreferrer"&gt;Find the CNPJ of Google Maps places&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://apify.com/jeffev/cnpj-por-dominio" rel="noopener noreferrer"&gt;CNPJ by website domain or e-mail&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you've worked with Brazilian open data and hit another trap, I'd love to hear about it in the comments.&lt;/p&gt;

</description>
      <category>duckdb</category>
      <category>python</category>
      <category>opendata</category>
      <category>dataengineering</category>
    </item>
  </channel>
</rss>
