<?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: Vahid Aghajani</title>
    <description>The latest articles on DEV Community by Vahid Aghajani (@vahid_aghajani_60ce9dbec9).</description>
    <link>https://dev.to/vahid_aghajani_60ce9dbec9</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%2F4015358%2F35ccb2f9-355f-4af6-a004-19ae755a9d8c.png</url>
      <title>DEV Community: Vahid Aghajani</title>
      <link>https://dev.to/vahid_aghajani_60ce9dbec9</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/vahid_aghajani_60ce9dbec9"/>
    <language>en</language>
    <item>
      <title>SQLite FTS5: How Full-Text Search Actually Works (Inverted Index + BM25)</title>
      <dc:creator>Vahid Aghajani</dc:creator>
      <pubDate>Sat, 25 Jul 2026 08:24:17 +0000</pubDate>
      <link>https://dev.to/vahid_aghajani_60ce9dbec9/sqlite-fts5-how-full-text-search-actually-works-inverted-index-bm25-2kl8</link>
      <guid>https://dev.to/vahid_aghajani_60ce9dbec9/sqlite-fts5-how-full-text-search-actually-works-inverted-index-bm25-2kl8</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;📺 Prefer to watch? &lt;a href="https://youtube.com/shorts/myJBHtmUFlo" rel="noopener noreferrer"&gt;90-second YouTube Short&lt;/a&gt; · 💬 &lt;a href="https://t.me/SoftwareEngineerBlog" rel="noopener noreferrer"&gt;Telegram&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://software-engineer-blog.com/content/sqlite-fts5-how-full-text-search-actually-works-inverted-index-bm25?id=131" rel="noopener noreferrer"&gt;software-engineer-blog.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;You have a folder of &lt;strong&gt;10,000 markdown notes&lt;/strong&gt; and you search for &lt;code&gt;postgres backup&lt;/code&gt;. &lt;code&gt;grep&lt;/code&gt; takes &lt;strong&gt;400 ms&lt;/strong&gt; and hands back &lt;strong&gt;40 files in the order they happen to sit on disk&lt;/strong&gt;. SQLite FTS5 takes &lt;strong&gt;3 ms&lt;/strong&gt; and puts the right note first.&lt;/p&gt;

&lt;p&gt;Same files. Same query. The entire difference is two ideas: an &lt;strong&gt;inverted index&lt;/strong&gt; and &lt;strong&gt;BM25 ranking&lt;/strong&gt;. Both fit in your head, and both fit in one SQLite file — no Elasticsearch cluster, no search service to babysit.&lt;/p&gt;




&lt;h2&gt;
  
  
  Start honest: grep is not wrong
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;grep -r "postgres backup" notes/&lt;/code&gt; opens &lt;strong&gt;every note and reads every byte&lt;/strong&gt;. At 300 notes that is genuinely the correct answer — the simplest thing that works. At 10,000 notes it re-reads all of them on &lt;strong&gt;every keystroke&lt;/strong&gt;, and your 400 ms search-as-you-type feels like typing through mud.&lt;/p&gt;

&lt;p&gt;But speed is only half the problem, and it's the half everyone notices. The half that quietly hurts more: &lt;strong&gt;grep cannot rank&lt;/strong&gt;. A tight 200-word note that &lt;em&gt;is about&lt;/em&gt; postgres backups and a standup note that mentions "postgres backup" once in paragraph nine come back &lt;strong&gt;identical&lt;/strong&gt; — two matching file paths, in folder order. grep knows &lt;em&gt;containment&lt;/em&gt;. It has no concept of &lt;em&gt;relevance&lt;/em&gt;.&lt;/p&gt;




&lt;h2&gt;
  
  
  The flip: store "word → files", not "file → words"
&lt;/h2&gt;

&lt;p&gt;Every file on disk is already a mapping of &lt;em&gt;file → the words in it&lt;/em&gt;. Full-text search flips that around and stores &lt;em&gt;word → the files it appears in&lt;/em&gt;. That's the whole trick — the &lt;strong&gt;inverted index&lt;/strong&gt; — and in SQLite it's one line:&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;CREATE&lt;/span&gt; &lt;span class="n"&gt;VIRTUAL&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;notes&lt;/span&gt; &lt;span class="k"&gt;USING&lt;/span&gt; &lt;span class="n"&gt;fts5&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt; &lt;span class="n"&gt;UNINDEXED&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;title&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Under the hood, FTS5 keeps a &lt;strong&gt;postings list&lt;/strong&gt; per term: for &lt;code&gt;postgres&lt;/code&gt;, a list of &lt;code&gt;(docid, column, position)&lt;/code&gt; entries — which note, which column, and &lt;em&gt;where in the text&lt;/em&gt; the word sits.&lt;/p&gt;

&lt;p&gt;Now run the query:&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;SELECT&lt;/span&gt; &lt;span class="n"&gt;path&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;notes&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;notes&lt;/span&gt; &lt;span class="k"&gt;MATCH&lt;/span&gt; &lt;span class="s1"&gt;'postgres backup'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;FTS5 reads the postings list for &lt;code&gt;postgres&lt;/code&gt;, the postings list for &lt;code&gt;backup&lt;/code&gt;, intersects them — and touches &lt;strong&gt;nothing else&lt;/strong&gt;. Files scanned: &lt;strong&gt;0&lt;/strong&gt;. That's the 400 ms → 3 ms jump: the work is proportional to the query's terms, not to the corpus.&lt;/p&gt;

&lt;p&gt;And because the index stores &lt;em&gt;positions&lt;/em&gt;, not just membership, you get two things grep can only fake with regex:&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="c1"&gt;-- exact phrase: the words must be adjacent, in order&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;path&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;notes&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;notes&lt;/span&gt; &lt;span class="k"&gt;MATCH&lt;/span&gt; &lt;span class="s1"&gt;'"postgres backup"'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;-- proximity: within 5 tokens of each other&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;path&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;notes&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;notes&lt;/span&gt; &lt;span class="k"&gt;MATCH&lt;/span&gt; &lt;span class="s1"&gt;'NEAR(postgres backup, 5)'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  The part that trips everyone: the ranking is NOT stored
&lt;/h2&gt;

&lt;p&gt;Here's the correction beat. It's tempting to imagine the index stores a rank next to each posting — "this note is a 9/10 for postgres". &lt;strong&gt;It doesn't, and it can't.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;BM25 — the ranking function FTS5 uses — is computed &lt;strong&gt;at query time&lt;/strong&gt;, from three inputs:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Term frequency (TF):&lt;/strong&gt; how often the term appears in &lt;em&gt;this&lt;/em&gt; note. More mentions → more likely the note is actually about it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Inverse document frequency (IDF):&lt;/strong&gt; how &lt;em&gt;rare&lt;/em&gt; the term is across the corpus. If &lt;code&gt;postgres&lt;/code&gt; appears in 12 notes and &lt;code&gt;backup&lt;/code&gt; in 3,000, a &lt;code&gt;postgres&lt;/code&gt; hit carries far more signal — the rare term dominates the score.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Length normalization:&lt;/strong&gt; a 5,000-word brain-dump can't out-score a tight 200-word note by sheer bulk. Frequency is judged relative to document length.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The reason it can't be precomputed: BM25 scores a &lt;strong&gt;pair&lt;/strong&gt; — &lt;em&gt;this query&lt;/em&gt; against &lt;em&gt;this document&lt;/em&gt;. The same note scores differently for &lt;code&gt;postgres&lt;/code&gt; than for &lt;code&gt;postgres backup&lt;/code&gt;. A stored per-note rank isn't even well defined.&lt;/p&gt;




&lt;h2&gt;
  
  
  The real query — and the negative-number gotcha
&lt;/h2&gt;

&lt;p&gt;Production search almost always wants a title hit to outrank a body hit. BM25's column weights do exactly that:&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;SELECT&lt;/span&gt; &lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;bm25&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;notes&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;rank&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;notes&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;notes&lt;/span&gt; &lt;span class="k"&gt;MATCH&lt;/span&gt; &lt;span class="s1"&gt;'postgres backup'&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;rank&lt;/span&gt;
&lt;span class="k"&gt;LIMIT&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two details bite here:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The weights are &lt;strong&gt;positional&lt;/strong&gt; — one per declared column, &lt;em&gt;including&lt;/em&gt; the &lt;code&gt;UNINDEXED&lt;/code&gt; one. &lt;code&gt;0.0&lt;/code&gt; for &lt;code&gt;path&lt;/code&gt;, &lt;code&gt;10.0&lt;/code&gt; for &lt;code&gt;title&lt;/code&gt;, &lt;code&gt;1.0&lt;/code&gt; for &lt;code&gt;body&lt;/code&gt;: a title hit counts 10× a body hit.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;bm25()&lt;/code&gt; returns a negative number.&lt;/strong&gt; More relevant = more negative. So plain &lt;code&gt;ORDER BY rank&lt;/code&gt; ascending is &lt;em&gt;already&lt;/em&gt; best-first. Everyone writes &lt;code&gt;ORDER BY rank DESC&lt;/code&gt; exactly once, stares at the worst results on top, and never does it again.&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  grep vs FTS5, honestly
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;
      &lt;th&gt;&lt;/th&gt;
      &lt;th&gt;grep&lt;/th&gt;
      &lt;th&gt;SQLite FTS5&lt;/th&gt;
    &lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;strong&gt;Question it answers&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;Which files &lt;em&gt;contain&lt;/em&gt; this word?&lt;/td&gt;
      &lt;td&gt;Which file is this word most &lt;em&gt;about&lt;/em&gt;?&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;strong&gt;Work per query&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;Reads every byte of every file (~400 ms at 10k notes)&lt;/td&gt;
      &lt;td&gt;Reads only the query terms' postings lists (~3 ms)&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;strong&gt;Ranking&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;None — results in folder order&lt;/td&gt;
      &lt;td&gt;BM25 (TF × IDF × length norm), computed per query&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;strong&gt;Phrase / proximity&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;Regex gymnastics&lt;/td&gt;
      &lt;td&gt;Native — &lt;code&gt;"exact phrase"&lt;/code&gt;, &lt;code&gt;NEAR()&lt;/code&gt; from stored positions&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;strong&gt;Freshness&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;Always current — reads the real files&lt;/td&gt;
      &lt;td&gt;Derived copy — goes stale if you write outside the ingestion path&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;strong&gt;Setup&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;Zero&lt;/td&gt;
      &lt;td&gt;One &lt;code&gt;CREATE VIRTUAL TABLE&lt;/code&gt; + an ingestion step you now own&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;




&lt;h2&gt;
  
  
  The two costs nobody puts in the demo
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;1. The index is a derived copy — you now own a sync problem.&lt;/strong&gt; Edit a note in your editor, outside whatever code inserts into the FTS table, and the index goes &lt;em&gt;quietly&lt;/em&gt; stale. It will keep answering, confidently, from old text. Stale-but-confident is strictly worse than grep's honest "no match" — grep at least never lies about the current state of disk.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. BM25 is purely lexical.&lt;/strong&gt; It counts tokens. It has no idea &lt;code&gt;car&lt;/code&gt; and &lt;code&gt;automobile&lt;/code&gt; are related, that &lt;code&gt;pg_dump&lt;/code&gt; is about postgres backups, or that "restore my database" and "postgres backup" are the same intent. That's not a bug in FTS5 — it's the ceiling of lexical search, and exactly where semantic/vector search begins.&lt;/p&gt;




&lt;h2&gt;
  
  
  The AI angle: why RAG pipelines still run BM25
&lt;/h2&gt;

&lt;p&gt;If you're building retrieval for an LLM — a RAG system over docs, tickets, or a codebase — this isn't retro plumbing. It's half of the current best practice.&lt;/p&gt;

&lt;p&gt;Embedding search has the &lt;em&gt;opposite&lt;/em&gt; failure mode to BM25: it knows &lt;code&gt;car ≈ automobile&lt;/code&gt;, but it's mushy on exact tokens. Ask a pure vector index for &lt;code&gt;ERR_CONN_RESET_1042&lt;/code&gt; or &lt;code&gt;bm25(notes, 0.0, 10.0, 1.0)&lt;/code&gt; and it happily returns text that's &lt;em&gt;semantically nearby&lt;/em&gt; while missing the one chunk containing the literal string. BM25 nails exact identifiers, function names, error codes, version strings — precisely the queries developers actually make.&lt;/p&gt;

&lt;p&gt;That's why production RAG stacks run &lt;strong&gt;hybrid search&lt;/strong&gt;: BM25 and vector similarity in parallel, merged with reciprocal rank fusion or a reranker. The inverted index + BM25 you just learned isn't the "old way" — it's the lexical leg of that hybrid, and with FTS5 it costs you one file and zero services. A SQLite database with an FTS5 table &lt;em&gt;and&lt;/em&gt; an embedding table is a legitimate, boringly reliable retrieval layer for a small-to-mid RAG system.&lt;/p&gt;

&lt;p&gt;And the mental model transfers one-to-one: IDF's "rare terms carry the signal" is the same instinct behind why a good chunking strategy keeps identifiers intact, and BM25's query-document &lt;em&gt;pair&lt;/em&gt; scoring is the same shape as a cross-encoder reranker — score the pair at query time, because relevance isn't a property of the document alone.&lt;/p&gt;




&lt;h2&gt;
  
  
  Verdict
&lt;/h2&gt;

&lt;p&gt;Under ~300 documents, grep (or &lt;code&gt;LIKE '%…%'&lt;/code&gt;) is genuinely fine — don't build an ingestion pipeline you don't need. The moment you need search-as-you-type or &lt;em&gt;ranked&lt;/em&gt; results, FTS5 gives you real search-engine machinery — inverted index, postings lists, BM25, phrase and proximity queries — for one &lt;code&gt;CREATE VIRTUAL TABLE&lt;/code&gt;, inside a database you're probably already shipping. Reach for a dedicated search service (or the vector leg) only when you hit FTS5's honest ceilings: cross-machine scale, typo tolerance, or synonym/semantic matching.&lt;/p&gt;

&lt;p&gt;grep asks &lt;em&gt;which files contain this word&lt;/em&gt;. FTS5 asks &lt;em&gt;which file is this word most about&lt;/em&gt;. That move — from containment to relevance — is the whole idea.&lt;/p&gt;

&lt;p&gt;🎥 &lt;strong&gt;Watch the 3-minute version:&lt;/strong&gt; &lt;a href="https://youtube.com/shorts/myJBHtmUFlo" rel="noopener noreferrer"&gt;SQLite FTS5 — inverted index + BM25, animated&lt;/a&gt;&lt;/p&gt;

</description>
      <category>sqlite</category>
      <category>programming</category>
      <category>softwareengineering</category>
      <category>database</category>
    </item>
    <item>
      <title>Next-Token Prediction: How an AI Actually Writes Text (Not Magic — Just Probability)</title>
      <dc:creator>Vahid Aghajani</dc:creator>
      <pubDate>Thu, 23 Jul 2026 17:48:21 +0000</pubDate>
      <link>https://dev.to/vahid_aghajani_60ce9dbec9/next-token-prediction-how-an-ai-actually-writes-text-not-magic-just-probability-53dd</link>
      <guid>https://dev.to/vahid_aghajani_60ce9dbec9/next-token-prediction-how-an-ai-actually-writes-text-not-magic-just-probability-53dd</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;📺 Prefer to watch? &lt;a href="https://youtu.be/vbnIZiyaFCE" rel="noopener noreferrer"&gt;90-second YouTube Short&lt;/a&gt; · 💬 &lt;a href="https://t.me/SoftwareEngineerBlog" rel="noopener noreferrer"&gt;Telegram&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://software-engineer-blog.com/content/next-token-prediction-how-an-ai-actually-writes-text-not-magic-just-probability?id=129" rel="noopener noreferrer"&gt;software-engineer-blog.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Ask an AI the same question twice. Get two different answers. That's not a glitch you tolerate — it's the entire mechanism working exactly as designed.&lt;/p&gt;

&lt;p&gt;Start below the buzzword: a language model never sees a finished sentence. It only ever answers &lt;strong&gt;one tiny question, over and over&lt;/strong&gt; — given everything written so far, what's the next chunk of text?&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;One-line mental model:&lt;/strong&gt; the model outputs a probability over every possible next token → it &lt;strong&gt;samples&lt;/strong&gt; from that distribution instead of always grabbing the top score → the winning token gets glued onto the text → the exact same question runs again from scratch, one token at a time.&lt;/p&gt;




&lt;h2&gt;
  
  
  The concrete example: finishing one sentence
&lt;/h2&gt;

&lt;p&gt;Say &lt;strong&gt;DraftPal&lt;/strong&gt;, a writing assistant, is finishing: &lt;em&gt;"The cat sat on the ___."&lt;/em&gt; It doesn't know the ending. It computes one probability for every possible next token it knows about:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;mat    → 41%
chair  → 19%
floor  → 12%
...    → (thousands more, trailing to ~0%)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's it. That's the entire "intelligence" at this step — a ranked list over the whole vocabulary, built fresh from the text so far.&lt;/p&gt;




&lt;h2&gt;
  
  
  The part almost everyone skips: it samples, it doesn't grab the top score
&lt;/h2&gt;

&lt;p&gt;Here's the detail that explains half the "weird" behavior people notice about LLMs: the model does &lt;strong&gt;not&lt;/strong&gt; deterministically pick &lt;code&gt;mat&lt;/code&gt; because it's the highest score. It &lt;strong&gt;samples&lt;/strong&gt; — a weighted die roll across that entire distribution. 41% wins most of the time. Sometimes &lt;code&gt;chair&lt;/code&gt; wins instead. Same model, same prompt, different word — because the die was rolled, not read off a table.&lt;/p&gt;

&lt;p&gt;Whatever wins gets glued onto the text, and the whole question — &lt;em&gt;"given everything so far, what's next?"&lt;/em&gt; — runs again from scratch, now one token longer. One token, one roll, repeat. That loop, run a few hundred times, is what writes an entire reply.&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="c1"&gt;# pseudocode — the entire generation loop
&lt;/span&gt;&lt;span class="n"&gt;tokens&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;tokenize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;prompt&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;while&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;done&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;distribution&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;model&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tokens&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;      &lt;span class="c1"&gt;# probability over every next token
&lt;/span&gt;    &lt;span class="n"&gt;next_token&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;sample&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;distribution&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="c1"&gt;# NOT always argmax
&lt;/span&gt;    &lt;span class="n"&gt;tokens&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;next_token&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  The chart isn't fixed — it's rebuilt from context every time
&lt;/h2&gt;

&lt;p&gt;Add four words of context before the same question — &lt;em&gt;"write this like a horror story"&lt;/em&gt; — and the exact same probability computation comes back totally different: &lt;code&gt;mat&lt;/code&gt; collapses under 1%, &lt;code&gt;coffin&lt;/code&gt; jumps to 99%. Nothing about the model changed. The &lt;strong&gt;input context&lt;/strong&gt; changed, so the distribution it computes changed.&lt;/p&gt;

&lt;p&gt;This one mechanism quietly explains two things developers run into constantly:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Why the same prompt gives two different replies on two runs.&lt;/strong&gt; No hidden state, no bug — it's sampling from a distribution, and the die comes up differently.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;What "personalization" actually is.&lt;/strong&gt; A model doesn't &lt;em&gt;know&lt;/em&gt; you. Your prior messages get stuffed back into the context window on every call, which reshapes the same probability chart toward tokens that fit what you've said before. It's context, not memory.&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
  &lt;thead&gt;&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Greedy (always top score)&lt;/th&gt;
&lt;th&gt;Sampling (the real default)&lt;/th&gt;
&lt;/tr&gt;&lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
&lt;td&gt;Determinism&lt;/td&gt;
&lt;td&gt;Same input → same output, always&lt;/td&gt;
&lt;td&gt;Same input → can vary run to run&lt;/td&gt;
&lt;/tr&gt;
    &lt;tr&gt;
&lt;td&gt;Variety&lt;/td&gt;
&lt;td&gt;Low — often repetitive/boring&lt;/td&gt;
&lt;td&gt;Higher — natural-sounding variation&lt;/td&gt;
&lt;/tr&gt;
    &lt;tr&gt;
&lt;td&gt;Reproducibility&lt;/td&gt;
&lt;td&gt;Perfect&lt;/td&gt;
&lt;td&gt;Traded away for the variety&lt;/td&gt;
&lt;/tr&gt;
    &lt;tr&gt;
&lt;td&gt;Typical use&lt;/td&gt;
&lt;td&gt;Structured/deterministic tasks (code, JSON)&lt;/td&gt;
&lt;td&gt;Open-ended writing, chat, brainstorming&lt;/td&gt;
&lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;




&lt;h2&gt;
  
  
  What it costs, and where it fails
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;The determinism/variety trade-off.&lt;/strong&gt; Force the model to always take the top slot (greedy decoding, or "temperature 0") and answers get boringly identical every run — useful when you need reproducibility, e.g. structured extraction. Leave sampling on and you get natural variety, at the cost of never getting the exact same output twice.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The compute cost is per token, not per reply.&lt;/strong&gt; Every single token — not the whole response — costs one full forward pass through the model. A 500-token answer is roughly 500 times more expensive than a 1-token answer, not "a bit more." This is also why streaming feels slow on long outputs: you're watching the loop happen in real time.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;No going back — the seed of a hallucination.&lt;/strong&gt; Once a token is glued onto the context, it is never revised. The model doesn't get to reconsider token 40 after generating token 41. So one confident wrong guess early on doesn't get corrected — the next question is now "given everything so far, including that wrong guess, what's next?" — and the model builds forward on its own mistake. That's the actual mechanical origin of a hallucination: not "the model lied," but "the model committed to a token and the loop only moves forward."&lt;/p&gt;




&lt;h2&gt;
  
  
  Reframe: this is also the whole story behind LLM-serving latency
&lt;/h2&gt;

&lt;p&gt;If you've ever looked at an inference dashboard, two metrics show up everywhere: &lt;strong&gt;TTFT&lt;/strong&gt; (time-to-first-token) and &lt;strong&gt;TPOT&lt;/strong&gt; (time-per-output-token). This loop is exactly what they're measuring.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;TTFT&lt;/strong&gt; is the cost of that &lt;em&gt;first&lt;/em&gt; forward pass — reading the whole prompt and producing the first probability distribution.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;TPOT&lt;/strong&gt; is the cost of every &lt;em&gt;subsequent&lt;/em&gt; iteration of the loop above — one more forward pass per token, forever, until the model samples a stop token.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That's also why &lt;strong&gt;batching&lt;/strong&gt; exists as a serving technique: since each loop iteration is bottlenecked on loading the model's weights into the GPU's compute units rather than on the arithmetic itself, serving frameworks pack multiple users' next-token requests into the same forward pass so one expensive weight-load produces many tokens at once. And it's why response length is the single biggest lever on cost and latency in any LLM product — you are quite literally paying for the number of times the loop above has to run.&lt;/p&gt;




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

&lt;p&gt;Not a sentence writer. A next-token predictor, running in a loop — one probability chart, one weighted roll, one token glued on, repeated until it samples a stop.&lt;/p&gt;

&lt;p&gt;Two devs run the same prompt through the same model. One gets &lt;em&gt;"…sat on the mat,"&lt;/em&gt; the other gets &lt;em&gt;"…the windowsill."&lt;/em&gt; Neither is wrong. That's not inconsistency — that's the mechanism.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Want the full walkthrough with the running example built out end to end? &lt;a href="https://youtu.be/azuzIOPkOOI" rel="noopener noreferrer"&gt;Watch the long-form video.&lt;/a&gt; Or the &lt;a href="https://youtu.be/vbnIZiyaFCE" rel="noopener noreferrer"&gt;90-second cut&lt;/a&gt; if you just want the core loop.&lt;/p&gt;
&lt;/blockquote&gt;

</description>
      <category>llm</category>
      <category>programming</category>
      <category>softwareengineering</category>
    </item>
    <item>
      <title>Speculative Decoding, Explained: Free LLM Speed With Zero Quality Loss</title>
      <dc:creator>Vahid Aghajani</dc:creator>
      <pubDate>Thu, 23 Jul 2026 06:33:26 +0000</pubDate>
      <link>https://dev.to/vahid_aghajani_60ce9dbec9/speculative-decoding-explained-free-llm-speed-with-zero-quality-loss-176m</link>
      <guid>https://dev.to/vahid_aghajani_60ce9dbec9/speculative-decoding-explained-free-llm-speed-with-zero-quality-loss-176m</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;📺 Prefer to watch? &lt;a href="https://youtu.be/gR0C7VkHG7E" rel="noopener noreferrer"&gt;90-second YouTube Short&lt;/a&gt; · 💬 &lt;a href="https://t.me/SoftwareEngineerBlog" rel="noopener noreferrer"&gt;Telegram&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://software-engineer-blog.com/content/speculative-decoding-explained-free-llm-speed-with-zero-quality-loss?id=128" rel="noopener noreferrer"&gt;software-engineer-blog.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Autoregressive LLM decoding produces one token per forward pass. A 100-token answer means 100 sequential passes through the entire weight matrix. The arithmetic is trivial; the GPU memory trip is where all the time goes. You are bandwidth bound, not compute bound — the hardware sits idle waiting for data.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;One-line mental model:&lt;/strong&gt; Rent a small model to guess K tokens cheaply; verify all K+1 against the big model in one pass (same weight load); accept matches, correct the first mismatch, discard the rest.&lt;/p&gt;




&lt;h2&gt;
  
  
  Why Decoding Is Bottlenecked on Memory, Not Compute
&lt;/h2&gt;

&lt;p&gt;When you generate the next token, the transformer reads every weight in the model to produce a single logit vector. On a GPU:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Compute work:&lt;/strong&gt; a few billion floating-point operations.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Data movement:&lt;/strong&gt; 70 billion parameters × (2 to 8 bytes per parameter, depending on precision) loaded from GPU memory, then written back.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;On modern hardware, moving that much data takes orders of magnitude longer than computing with it. Each forward pass is a round trip to memory for almost no local work. The GPU's arithmetic units are starved. Increasing batch size helps when you have many prompts to process in parallel, but for a single sequence of generations — the most common case in real-time chat — you cannot hide that latency.&lt;/p&gt;

&lt;p&gt;This is the setup that speculative decoding exploits.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Core Idea: Draft Model Proposes, Big Model Verifies
&lt;/h2&gt;

&lt;p&gt;Instead of the big model generating one token at a time, use a smaller, cheaper draft model to propose K candidate tokens in a row:&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;Input&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;def quicksort(arr):&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;span class="n"&gt;Draft&lt;/span&gt; &lt;span class="nf"&gt;model &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="n"&gt;B&lt;/span&gt; &lt;span class="n"&gt;params&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;proposes&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
  &lt;span class="n"&gt;Token&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;if&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
  &lt;span class="n"&gt;Token&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;len&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
  &lt;span class="n"&gt;Token&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;:&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="n"&gt;Token&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;arr&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now, instead of running the big model (70B) four times — once per proposed token — you run it &lt;strong&gt;once&lt;/strong&gt; and ask it to score all five positions: the original input plus the four draft proposals. The big model's forward pass loads the entire weight matrix whether it generates one token or evaluates four. Verification is nearly free relative to generation.&lt;/p&gt;

&lt;p&gt;After that single pass, you compare the big model's chosen token at each position against what the draft model proposed:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Positions match:&lt;/strong&gt; accept the draft token and keep going.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;First position diverges:&lt;/strong&gt; replace it with the big model's token, discard all subsequent draft proposals, and stop (or re-run the draft model from that point).&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;In the best case, you accept all K proposed tokens and advance K steps with one big-model pass. In the worst case, you accept zero and advance one. Either way, you spent one weight-load trip on the big model.&lt;/p&gt;




&lt;h2&gt;
  
  
  Running Example: CodeCue, an In-Editor Code Assistant
&lt;/h2&gt;

&lt;p&gt;Imagine CodeCue, an IDE plugin that auto-completes code as you type. You're writing Python:&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="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;fibonacci&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;n&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;n&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt;
    &lt;span class="o"&gt;|&lt;/span&gt;  &lt;span class="c1"&gt;# cursor here
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Without speculative decoding:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Big model (Llama 70B) generates token 1: &lt;code&gt;return&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Reload weights, generate token 2: &lt;code&gt;fib&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Reload weights, generate token 3: &lt;code&gt;(&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Reload weights, generate token 4: &lt;code&gt;n&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Reload weights, generate token 5: &lt;code&gt;-&lt;/code&gt;
&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Four memory round trips for something as simple and predictable as a function call.&lt;/p&gt;

&lt;p&gt;With speculative decoding:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Draft model (CodeQwen 1B) quickly proposes: &lt;code&gt;return&lt;/code&gt;, &lt;code&gt;fib&lt;/code&gt;, &lt;code&gt;(&lt;/code&gt;, &lt;code&gt;n&lt;/code&gt;, &lt;code&gt;-&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Big model loads weights once, scores all six positions (original + five proposals).&lt;/li&gt;
&lt;li&gt;Big model agrees on the first four tokens. On position five, it chooses &lt;code&gt;1&lt;/code&gt; instead of &lt;code&gt;-&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Accept: &lt;code&gt;return fib(n - 1&lt;/code&gt;, correct the mismatch, discard anything after.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;One big-model weight load instead of five. The output is identical to what Llama 70B would have chosen on its own — the accept rule enforces it mathematically.&lt;/p&gt;




&lt;h2&gt;
  
  
  Mathematical Identity: Why Output Quality Is Preserved
&lt;/h2&gt;

&lt;p&gt;The accept rule is designed so that the joint distribution of accepted tokens matches what the big model would have produced alone. Here's why:&lt;/p&gt;

&lt;p&gt;At each position, when the big model disagrees with the draft, you take the big model's token. When they agree, you take the accepted token — but you only got there because the big model would have generated it at that position anyway. The prefix that made it through acceptance is a valid execution path of the big model's sampling procedure.&lt;/p&gt;

&lt;p&gt;Unlike quantization (which discards precision to trade quality for speed) or distillation (which accepts training-time accuracy loss), speculative decoding &lt;strong&gt;does not trade quality&lt;/strong&gt;. The output distribution is preserved exactly. You can verify this by running the same sequence through both approaches: they will produce identical token probabilities.&lt;/p&gt;




&lt;h2&gt;
  
  
  What Costs You: Acceptance Rate, VRAM, and Workload Shape
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
  &lt;tbody&gt;
&lt;tr&gt;
    &lt;th&gt;Cost&lt;/th&gt;
    &lt;th&gt;Impact&lt;/th&gt;
    &lt;th&gt;Mitigation&lt;/th&gt;
  &lt;/tr&gt;
  &lt;tr&gt;
    &lt;td&gt;Bad draft-model accuracy&lt;/td&gt;
    &lt;td&gt;Mismatches force rejections; you spend compute on draft tokens you discard. End-to-end latency can be worse than single-model decoding.&lt;/td&gt;
    &lt;td&gt;Match draft model capacity to the workload. Use a model trained on your domain (e.g., CodeQwen for code).&lt;/td&gt;
  &lt;/tr&gt;
  &lt;tr&gt;
    &lt;td&gt;Two models in VRAM&lt;/td&gt;
    &lt;td&gt;You need memory for both the draft (1–7B) and big model (70B+). On a single GPU, this is tight or infeasible.&lt;/td&gt;
    &lt;td&gt;Use smaller draft models (300M–1B). Offload draft to CPU in some setups (not recommended for low-latency).&lt;/td&gt;
  &lt;/tr&gt;
  &lt;tr&gt;
    &lt;td&gt;Workload-dependent gains&lt;/td&gt;
    &lt;td&gt;Boilerplate code, JSON, schema, structured output: high acceptance. Creative writing, reasoning, brainstorming: low acceptance.&lt;/td&gt;
    &lt;td&gt;Profile your use case. Disable speculative decoding for open-ended tasks; enable for code, API responses, structured data.&lt;/td&gt;
  &lt;/tr&gt;
  &lt;tr&gt;
    &lt;td&gt;Acceptance rate collapse&lt;/td&gt;
    &lt;td&gt;If draft and big model disagree frequently (e.g., different tokenizers, training data), you get no speedup or slowdown.&lt;/td&gt;
    &lt;td&gt;Use a draft model derived from or aligned with the big model.&lt;/td&gt;
  &lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;




&lt;h2&gt;
  
  
  Real-World Performance
&lt;/h2&gt;

&lt;p&gt;Typical wall-clock speedups in production:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Structured output (JSON, code, boilerplate):&lt;/strong&gt; 2–4×&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Mixed or conversational:&lt;/strong&gt; 1.5–2×&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Creative or long-reasoning chains:&lt;/strong&gt; close to 1× (most drafts rejected)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;These gains assume a well-tuned draft model and acceptance rates above 60–70%.&lt;/p&gt;




&lt;h2&gt;
  
  
  LLM Inference Context: TTFT vs. TPOT
&lt;/h2&gt;

&lt;p&gt;In LLM serving, speculative decoding affects &lt;strong&gt;time-per-output-token (TPOT)&lt;/strong&gt; — the latency to generate each successive token after the first. Time-to-first-token (TTFT) remains unchanged because the draft model alone cannot produce coherent output; you still need the big model's first forward pass to seed the sequence.&lt;/p&gt;

&lt;p&gt;If you use KV caching and continuous batching in your serving infrastructure, speculative decoding stacks orthogonally: caching reduces redundant computation within a sequence, batching amortizes weight loads across multiple prompts, and speculative decoding removes idle GPU cycles within a single sequence. None of these three optimizations make the others redundant.&lt;/p&gt;




&lt;h2&gt;
  
  
  When to Reach for Speculative Decoding
&lt;/h2&gt;

&lt;p&gt;Reach for speculative decoding when your workload is &lt;strong&gt;structured, predictable, and code-heavy&lt;/strong&gt; (code completion, API response generation, schema filling) &lt;strong&gt;and you have enough VRAM for two models&lt;/strong&gt;. Use single-model decoding when your task is &lt;strong&gt;creative, open-ended, or reasoning-heavy&lt;/strong&gt; (storytelling, math steps, brainstorming), &lt;strong&gt;VRAM is tight&lt;/strong&gt;, or &lt;strong&gt;acceptance rates are empirically below 40%&lt;/strong&gt;.&lt;/p&gt;




&lt;p&gt;Watch the 90-second reel on software-engineer-blog.com to see the full visual breakdown.&lt;/p&gt;

</description>
      <category>speculativedecoding</category>
      <category>programming</category>
      <category>softwareengineering</category>
    </item>
    <item>
      <title>Data Modeling Explained: From One Messy Table to a Real Schema</title>
      <dc:creator>Vahid Aghajani</dc:creator>
      <pubDate>Wed, 22 Jul 2026 16:03:29 +0000</pubDate>
      <link>https://dev.to/vahid_aghajani_60ce9dbec9/data-modeling-explained-from-one-messy-table-to-a-real-schema-4m34</link>
      <guid>https://dev.to/vahid_aghajani_60ce9dbec9/data-modeling-explained-from-one-messy-table-to-a-real-schema-4m34</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;📺 Prefer to watch? &lt;a href="https://youtu.be/eX8E_NqkUDo" rel="noopener noreferrer"&gt;90-second YouTube Short&lt;/a&gt; · 💬 &lt;a href="https://t.me/SoftwareEngineerBlog" rel="noopener noreferrer"&gt;Telegram&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://software-engineer-blog.com/content/data-modeling-explained-from-one-messy-table-to-a-real-schema?id=127" rel="noopener noreferrer"&gt;software-engineer-blog.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Your schema is the contract every pipeline, query, and dashboard depends on. Get the modeling wrong, and you'll spend 3 a.m. fixing silent duplicates and mismatched joins. Get it right, and the whole stack hums.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Mental model:&lt;/strong&gt; Data modeling is deciding which real-world nouns become tables, which facts live where, and how they reference each other—so you have one source of truth and every query knows where to look.&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  The Problem: One Fat Table
&lt;/h2&gt;

&lt;p&gt;Imagine you're building the backend for BeanBox, a coffee store that takes online orders. You start simple: one &lt;code&gt;orders&lt;/code&gt; table. Every row is an order.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;order_id | customer_name | customer_email | product_name | product_price | order_date
1        | Ada           | ada@b.co       | Espresso     | 3.50          | 2024-01-15
2        | Ada           | ada@b.co       | Latte        | 4.50          | 2024-01-16
3        | Bob           | bob@b.co       | Espresso     | 3.50          | 2024-01-16
4        | Ada           | ada@b.co       | Cappuccino   | 4.75          | 2024-01-17
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This works until Ada changes her email to &lt;code&gt;ada.new@b.co&lt;/code&gt;. Now you have two choices: update all four of her rows, or leave the old ones and live with inconsistency. Miss one row and you have two emails for one person. Your aggregations break. Your dashboard and your pipeline disagree on how many unique customers you have.&lt;/p&gt;

&lt;p&gt;This is called an &lt;strong&gt;update anomaly&lt;/strong&gt;—a sign that data is scattered where it should be singular.&lt;/p&gt;




&lt;h2&gt;
  
  
  Layer One: The Conceptual Model
&lt;/h2&gt;

&lt;p&gt;Start by naming the real-world things your system cares about:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Customer&lt;/strong&gt; — a person who buys coffee&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Order&lt;/strong&gt; — a purchase event&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Product&lt;/strong&gt; — something we sell&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Draw lines between them:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A Customer places many Orders (one-to-many)&lt;/li&gt;
&lt;li&gt;An Order contains one or more Products (many-to-many)&lt;/li&gt;
&lt;li&gt;A Product appears in many Orders&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This is the conceptual model: just nouns and relationships. No keys yet, no databases. Just: "What are the real things, and how do they connect?"&lt;/p&gt;




&lt;h2&gt;
  
  
  Layer Two: The Logical Model
&lt;/h2&gt;

&lt;p&gt;Now turn those nouns into tables and add structure.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Customer&lt;/strong&gt; becomes a table with:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;customer_id&lt;/code&gt; (primary key — the unique identifier for this row)&lt;/li&gt;
&lt;li&gt;&lt;code&gt;name&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;email&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Product&lt;/strong&gt; becomes a table with:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;product_id&lt;/code&gt; (primary key)&lt;/li&gt;
&lt;li&gt;&lt;code&gt;name&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;price&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Order&lt;/strong&gt; becomes a table with:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;order_id&lt;/code&gt; (primary key)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;customer_id&lt;/code&gt; (foreign key — references the Customer table)&lt;/li&gt;
&lt;li&gt;&lt;code&gt;order_date&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;OrderItem&lt;/strong&gt; (or OrderProduct) handles the many-to-many:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;order_item_id&lt;/code&gt; (primary key)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;order_id&lt;/code&gt; (foreign key)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;product_id&lt;/code&gt; (foreign key)&lt;/li&gt;
&lt;li&gt;&lt;code&gt;quantity&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Now an order does &lt;em&gt;not&lt;/em&gt; copy the customer's email. It just points to the customer with &lt;code&gt;customer_id&lt;/code&gt;. If Ada changes her email, you update the &lt;code&gt;customers&lt;/code&gt; table &lt;em&gt;once&lt;/em&gt;, and every order automatically references the new email.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;customers:
id | name | email
1  | Ada  | ada.new@b.co
2  | Bob  | bob@b.co

products:
id | name       | price
1  | Espresso   | 3.50
2  | Latte      | 4.50
3  | Cappuccino | 4.75

orders:
id | customer_id | order_date
1  | 1           | 2024-01-15
2  | 1           | 2024-01-16
3  | 2           | 2024-01-16
4  | 1           | 2024-01-17

order_items:
id | order_id | product_id | quantity
1  | 1        | 1          | 1
2  | 2        | 2          | 1
3  | 3        | 1          | 1
4  | 4        | 3          | 1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is normalization: every fact lives in exactly one place.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Three Relationships
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Relationship&lt;/th&gt;
&lt;th&gt;Pattern&lt;/th&gt;
&lt;th&gt;Implementation&lt;/th&gt;
&lt;th&gt;Example&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;One-to-Many&lt;/td&gt;
&lt;td&gt;One A owns many Bs&lt;/td&gt;
&lt;td&gt;B has a foreign key to A&lt;/td&gt;
&lt;td&gt;One Customer, many Orders. &lt;code&gt;orders.customer_id&lt;/code&gt; → &lt;code&gt;customers.id&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;One-to-One&lt;/td&gt;
&lt;td&gt;One A is exactly one B&lt;/td&gt;
&lt;td&gt;Either table has a foreign key to the other; often separate for security or audit&lt;/td&gt;
&lt;td&gt;One User, one EncryptedPassword. &lt;code&gt;passwords.user_id&lt;/code&gt; → &lt;code&gt;users.id&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Many-to-Many&lt;/td&gt;
&lt;td&gt;Many As relate to many Bs&lt;/td&gt;
&lt;td&gt;Join table with two foreign keys&lt;/td&gt;
&lt;td&gt;Many Orders, many Products. &lt;code&gt;order_items.order_id&lt;/code&gt; → &lt;code&gt;orders.id&lt;/code&gt;, &lt;code&gt;order_items.product_id&lt;/code&gt; → &lt;code&gt;products.id&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;




&lt;h2&gt;
  
  
  Normalization: One Source of Truth
&lt;/h2&gt;

&lt;p&gt;Normalization is a set of rules (Normal Forms: 1NF, 2NF, 3NF, BCNF, and beyond) that ensure:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Every fact lives in exactly one place&lt;/strong&gt; — no redundant copies&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Updates are atomic&lt;/strong&gt; — change an email once, it's updated everywhere&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No insertion anomalies&lt;/strong&gt; — you can add a new customer without creating a fake order&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No deletion anomalies&lt;/strong&gt; — you can delete an order without losing customer information&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;For BeanBox, your normalized schema means:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Change Ada's email one time in the &lt;code&gt;customers&lt;/code&gt; table&lt;/li&gt;
&lt;li&gt;Every query that joins orders to customers sees the new email&lt;/li&gt;
&lt;li&gt;No silent mismatches, no data rot&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Layer Three: The Physical Model
&lt;/h2&gt;

&lt;p&gt;Once you've normalized, the database gets real:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;You pick data types: &lt;code&gt;customer_id&lt;/code&gt; is a &lt;code&gt;BIGINT&lt;/code&gt;, &lt;code&gt;email&lt;/code&gt; is a &lt;code&gt;VARCHAR(255)&lt;/code&gt;, &lt;code&gt;price&lt;/code&gt; is a &lt;code&gt;DECIMAL(10,2)&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;You add indexes: put an index on &lt;code&gt;orders.customer_id&lt;/code&gt; so joins are fast&lt;/li&gt;
&lt;li&gt;You decide on denormalization &lt;em&gt;on purpose&lt;/em&gt;
&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  When to Denormalize
&lt;/h2&gt;

&lt;p&gt;Normalization is for correctness: one source of truth, no anomalies, correct writes. But analytics has different demands: &lt;strong&gt;you want wide, flat tables so queries avoid expensive joins&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;This is denormalization, and it's not cheating—it's deliberate and separate.&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="c1"&gt;-- Normalized: correct, but six joins&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt;
  &lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="k"&gt;COUNT&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;order_count&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="k"&gt;SUM&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;oi&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;quantity&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;price&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;revenue&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;customers&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;
&lt;span class="k"&gt;LEFT&lt;/span&gt; &lt;span class="k"&gt;JOIN&lt;/span&gt; &lt;span class="n"&gt;orders&lt;/span&gt; &lt;span class="n"&gt;o&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;customer_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;
&lt;span class="k"&gt;LEFT&lt;/span&gt; &lt;span class="k"&gt;JOIN&lt;/span&gt; &lt;span class="n"&gt;order_items&lt;/span&gt; &lt;span class="n"&gt;oi&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;oi&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;order_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;
&lt;span class="k"&gt;LEFT&lt;/span&gt; &lt;span class="k"&gt;JOIN&lt;/span&gt; &lt;span class="n"&gt;products&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;oi&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;product_id&lt;/span&gt;
&lt;span class="k"&gt;GROUP&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;-- Denormalized: wide, one table, fast read&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;customer_name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;order_count&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;revenue&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;customer_revenue_summary&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For an operational database (OLTP), normalize. For a data warehouse or analytics layer (OLAP), denormalize into a star schema or wide fact tables. Build the wide table from the normalized source on a schedule—Airflow, dbt, whatever.&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="c1"&gt;-- dbt: build the wide table from normalized sources&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt;
  &lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;customer_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;customer_name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;email&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;order_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;order_date&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;product_name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;price&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;oi&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;quantity&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;price&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;oi&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;quantity&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;line_total&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;customers&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;
&lt;span class="k"&gt;LEFT&lt;/span&gt; &lt;span class="k"&gt;JOIN&lt;/span&gt; &lt;span class="n"&gt;orders&lt;/span&gt; &lt;span class="n"&gt;o&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;customer_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;
&lt;span class="k"&gt;LEFT&lt;/span&gt; &lt;span class="k"&gt;JOIN&lt;/span&gt; &lt;span class="n"&gt;order_items&lt;/span&gt; &lt;span class="n"&gt;oi&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;oi&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;order_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;
&lt;span class="k"&gt;LEFT&lt;/span&gt; &lt;span class="k"&gt;JOIN&lt;/span&gt; &lt;span class="n"&gt;products&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;oi&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;product_id&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then your dashboard query is a single table scan. Fast, explicit, and the denormalization is version-controlled.&lt;/p&gt;




&lt;h2&gt;
  
  
  Why the Schema Matters for Data Engineering
&lt;/h2&gt;

&lt;p&gt;Your schema is a contract. It says:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;"Here are the tables and their columns"&lt;/li&gt;
&lt;li&gt;"Here are the primary and foreign keys"&lt;/li&gt;
&lt;li&gt;"Here are the data types"&lt;/li&gt;
&lt;li&gt;"Here is the one source of truth for each fact"&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Every pipeline, query, and dashboard bets on that contract. When you change it without planning:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A pipeline that expected &lt;code&gt;orders.customer_id&lt;/code&gt; breaks if you rename it&lt;/li&gt;
&lt;li&gt;A dashboard that counts distinct customers breaks if you suddenly allow NULL in &lt;code&gt;customer_id&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Two teams build different logic to join &lt;code&gt;orders&lt;/code&gt; to &lt;code&gt;customers&lt;/code&gt; because the relationship was never documented&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A clean schema prevents silent bugs. It's the difference between 3 a.m. on-call and sleeping through the night.&lt;/p&gt;




&lt;h2&gt;
  
  
  For LLM-Serving Systems
&lt;/h2&gt;

&lt;p&gt;If you're building a system where an LLM needs to query a database (e.g., a retrieval-augmented generation pipeline), data modeling becomes a latency surface:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;TTFT (time to first token)&lt;/strong&gt; depends on query latency to fetch context. A poorly normalized schema with N+1 join problems will serialize your token generation.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;TPOT (time per output token)&lt;/strong&gt; stays constant, but the prefill depends on context window size, which depends on how much data you can fetch in time.&lt;/li&gt;
&lt;li&gt;Denormalization (wide tables, materialized views, embedding caches) reduces query latency and gets you context faster, letting the LLM start generating tokens sooner.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Your schema design directly affects how fast a user sees a response from an LLM-powered application.&lt;/p&gt;




&lt;h2&gt;
  
  
  Verdict
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Normalize for correct writes and one source of truth.&lt;/strong&gt; Use normalized schemas in operational databases (OLTP) and data sources of record. &lt;strong&gt;Denormalize deliberately for analytics.&lt;/strong&gt; Build wide, flat tables in your warehouse using dbt or similar tools; denormalization is a choice, not a side effect.&lt;/p&gt;

&lt;p&gt;If you're unsure whether a fact should be split across tables, ask: "If this value changes, how many places would I have to update it?" If the answer is more than one, normalize it.&lt;/p&gt;

&lt;p&gt;Watch the 90-second reel to see this unfold on one live example.&lt;/p&gt;

</description>
      <category>datamodelingindataengineering</category>
      <category>programming</category>
      <category>softwareengineering</category>
    </item>
    <item>
      <title>GeoParquet Explained: Your Geodata Has Two Shapes (One You Edit, One You Scan)</title>
      <dc:creator>Vahid Aghajani</dc:creator>
      <pubDate>Tue, 21 Jul 2026 18:03:46 +0000</pubDate>
      <link>https://dev.to/vahid_aghajani_60ce9dbec9/geoparquet-explained-your-geodata-has-two-shapes-one-you-edit-one-you-scan-50n0</link>
      <guid>https://dev.to/vahid_aghajani_60ce9dbec9/geoparquet-explained-your-geodata-has-two-shapes-one-you-edit-one-you-scan-50n0</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;📺 Prefer to watch? &lt;a href="https://youtu.be/rMbTMVLjdik" rel="noopener noreferrer"&gt;90-second YouTube Short&lt;/a&gt; · 💬 &lt;a href="https://t.me/SoftwareEngineerBlog" rel="noopener noreferrer"&gt;Telegram&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://software-engineer-blog.com/content/geoparquet-explained-your-geodata-has-two-shapes-one-you-edit-one-you-scan?id=126" rel="noopener noreferrer"&gt;software-engineer-blog.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Take one dataset — call it &lt;strong&gt;LandGrid&lt;/strong&gt;, 200 million land parcels, each with a polygon and about forty attributes — and ask it two questions.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Question one, from a surveyor:&lt;/strong&gt; &lt;em&gt;"Parcel 4,182,930 got resurveyed. Move its eastern boundary two metres and save it."&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Question two, from an analyst:&lt;/strong&gt; &lt;em&gt;"Total assessed value of every residential parcel in the country, grouped by county."&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Same data. Same disk. One question finishes in a millisecond, the other takes six minutes and reads 400 GB. That is not a tuning problem, and no index will fix it. It's the storage layout telling you the truth: &lt;strong&gt;your geodata has two shapes, and you are only storing one of them.&lt;/strong&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  First principles: what a row actually is on disk
&lt;/h2&gt;

&lt;p&gt;Forget databases for a second and think about bytes.&lt;/p&gt;

&lt;p&gt;In PostGIS, a parcel is a &lt;strong&gt;row&lt;/strong&gt;, and a row is stored &lt;strong&gt;contiguously&lt;/strong&gt;. Parcel ID, owner, county, zoning code, assessed value, thirty-five more fields, and then the polygon geometry as WKB — all glued together, one after another, in the same disk page.&lt;/p&gt;

&lt;p&gt;That layout has one enormous virtue: &lt;strong&gt;everything about one feature is in one place.&lt;/strong&gt; The surveyor's query hits an index, the index points at a page, one read pulls the whole parcel, you edit it in place, you commit. Row-oriented storage plus an R-tree index is the correct answer to &lt;em&gt;"fetch this one thing and change it."&lt;/em&gt; This is OLTP, and PostGIS is excellent at it.&lt;/p&gt;

&lt;p&gt;Now run the analyst's query against that same layout. It needs exactly two columns: &lt;code&gt;zoning&lt;/code&gt; and &lt;code&gt;assessed_value&lt;/code&gt;. But the columns are &lt;strong&gt;glued into the rows&lt;/strong&gt;. To read two fields from 200 million rows you must walk 200 million rows — which means dragging all forty fields off disk, geometry included, because the polygon sits physically between the value you want on this row and the value you want on the next one.&lt;/p&gt;

&lt;p&gt;You needed 2 of 40 columns. You read 40. And the largest of them, the geometry, was never even referenced by the query.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;An index cannot rescue this.&lt;/strong&gt; Indexes exist to &lt;em&gt;avoid looking at rows&lt;/em&gt;. The analyst's query looks at every row on purpose. Nothing is being looked up; everything is being read. So the only thing left to change is the physical layout itself.&lt;/p&gt;




&lt;h2&gt;
  
  
  Flip the layout
&lt;/h2&gt;

&lt;p&gt;Store the file &lt;strong&gt;column by column&lt;/strong&gt; instead of row by row. All 200 million zoning codes contiguously, then all 200 million assessed values, then all the geometries in their own block at the end.&lt;/p&gt;

&lt;p&gt;Now the analyst's query reads two contiguous runs of bytes and stops. That is &lt;strong&gt;column pruning&lt;/strong&gt;, and it is not a clever optimisation — it falls straight out of the layout. The geometry column is never touched because the reader never has to step over it.&lt;/p&gt;

&lt;p&gt;You get a second win for free. A column holds one kind of value, so a run of bytes is 200 million zoning codes rather than an alternating mess of ints, strings and blobs. Dictionary encoding, run-length encoding and general-purpose compression all work far better on homogeneous data than on interleaved records. Columnar geospatial files routinely land several times smaller than the row-oriented equivalent.&lt;/p&gt;

&lt;p&gt;This layout is &lt;strong&gt;Apache Parquet&lt;/strong&gt;. It is a file format, not a database, and it has been the standard analytical file in the data world for a decade.&lt;/p&gt;




&lt;h2&gt;
  
  
  GeoParquet is a convention, not a database
&lt;/h2&gt;

&lt;p&gt;Here is the part people get wrong. GeoParquet is &lt;strong&gt;not&lt;/strong&gt; a new format, not a fork of Parquet, not a query engine, and not "an OLAP system." It is a &lt;strong&gt;thin metadata convention&lt;/strong&gt; written on top of ordinary Parquet.&lt;/p&gt;

&lt;p&gt;A GeoParquet file is a Parquet file with a &lt;code&gt;geo&lt;/code&gt; key in the file-level metadata. That key declares:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;the &lt;strong&gt;primary geometry column&lt;/strong&gt; (and any secondary ones),&lt;/li&gt;
&lt;li&gt;the &lt;strong&gt;encoding&lt;/strong&gt; — WKB by default, or native &lt;strong&gt;GeoArrow&lt;/strong&gt; since GeoParquet 1.1,&lt;/li&gt;
&lt;li&gt;the &lt;strong&gt;CRS&lt;/strong&gt;, as &lt;strong&gt;PROJJSON&lt;/strong&gt; (not a bare EPSG integer),&lt;/li&gt;
&lt;li&gt;the &lt;strong&gt;geometry types&lt;/strong&gt; present,&lt;/li&gt;
&lt;li&gt;the &lt;strong&gt;bbox&lt;/strong&gt; of the data,&lt;/li&gt;
&lt;li&gt;and the &lt;strong&gt;edge semantics&lt;/strong&gt; — planar or spherical.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That's essentially it. The consequence is the good part: &lt;strong&gt;any Parquet reader can still open the file.&lt;/strong&gt; Pandas, Spark, BigQuery, a plain &lt;code&gt;parquet-tools&lt;/code&gt; dump — none of them break. They just see a binary column. A geo-aware reader looks at the &lt;code&gt;geo&lt;/code&gt; metadata and knows those bytes are polygons in a specific CRS. It's the same trick as WKB inside a database column, moved up to the file level.&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="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;duckdb&lt;/span&gt;

&lt;span class="n"&gt;con&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;duckdb&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;connect&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="n"&gt;con&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;INSTALL spatial; LOAD spatial;&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;con&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;INSTALL httpfs; LOAD httpfs;&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="n"&gt;con&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sql&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 county, SUM(assessed_value) AS total
    FROM &lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;landgrid.parquet&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;
    WHERE zoning = &lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;RES&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;
    GROUP BY county
    ORDER BY total DESC
&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;show&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No server, no import step, no &lt;code&gt;CREATE TABLE&lt;/code&gt;. The engine opens the file, reads the footer, and touches two columns.&lt;/p&gt;




&lt;h2&gt;
  
  
  Row groups, statistics, and the 1.1 bbox column
&lt;/h2&gt;

&lt;p&gt;Column pruning gets you from 40 columns to 2. The next win is skipping &lt;strong&gt;rows&lt;/strong&gt; — and this is where Parquet's internal structure matters.&lt;/p&gt;

&lt;p&gt;A Parquet file is cut into &lt;strong&gt;row groups&lt;/strong&gt;: horizontal slices, typically tens to hundreds of megabytes. Each row group stores each column as a separate chunk, and — crucially — each chunk carries &lt;strong&gt;statistics&lt;/strong&gt;: min, max, null count.&lt;/p&gt;

&lt;p&gt;So a reader handling &lt;code&gt;WHERE assessed_value &amp;gt; 1000000&lt;/code&gt; opens the footer, walks the row-group statistics, and discards whole row groups whose max is below the threshold &lt;strong&gt;without decompressing a single byte of them&lt;/strong&gt;. This is predicate pushdown, and it happens before any real I/O.&lt;/p&gt;

&lt;p&gt;For geometry, min/max on a WKB blob is meaningless — which is why &lt;strong&gt;GeoParquet 1.1 added the bbox covering column&lt;/strong&gt;: a struct column of &lt;code&gt;xmin&lt;/code&gt;, &lt;code&gt;ymin&lt;/code&gt;, &lt;code&gt;xmax&lt;/code&gt;, &lt;code&gt;ymax&lt;/code&gt; per row, whose &lt;em&gt;own&lt;/em&gt; Parquet statistics per row group give you the spatial extent of that row group. A bbox query can now drop row groups the same way a numeric filter does.&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="c1"&gt;-- with a 1.1 bbox covering column, this prunes row groups&lt;/span&gt;
&lt;span class="c1"&gt;-- instead of decoding 200 million polygons&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="s1"&gt;'landgrid.parquet'&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;bbox&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;xmin&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;8&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;6&lt;/span&gt; &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;bbox&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;xmax&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;8&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;4&lt;/span&gt;
  &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;bbox&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ymin&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;47&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;5&lt;/span&gt; &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;bbox&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ymax&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;47&lt;/span&gt;&lt;span class="p"&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;h2&gt;
  
  
  The Hilbert trap: skipping is only as good as your sort order
&lt;/h2&gt;

&lt;p&gt;Now the part nobody warns you about, and the single biggest reason a GeoParquet file underperforms in practice.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Row-group skipping only works if the rows were physically sorted before the file was written.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Write LandGrid in insertion order — the order the parcels happened to arrive, county by county over twenty years, or worse, arbitrary — and every row group ends up holding a random scattering of parcels from all over the country. Every row group's bbox is therefore roughly &lt;em&gt;the whole country&lt;/em&gt;. Every row group overlaps your query. Nothing gets skipped. You scan the entire file and wonder why the format everyone praised is slow.&lt;/p&gt;

&lt;p&gt;The fix is to sort on a &lt;strong&gt;space-filling curve&lt;/strong&gt; — usually &lt;strong&gt;Hilbert&lt;/strong&gt; — at write time, so that rows near each other in 2D end up near each other in the file:&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;COPY&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="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ST_Extent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;geom&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;bbox&lt;/span&gt;
    &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;parcels&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;ST_Hilbert&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;geom&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;ST_Extent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ST_MakeEnvelope&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="mi"&gt;9&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;45&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;8&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;10&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="mi"&gt;47&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;8&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;TO&lt;/span&gt; &lt;span class="s1"&gt;'landgrid.parquet'&lt;/span&gt;
  &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;FORMAT&lt;/span&gt; &lt;span class="n"&gt;PARQUET&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;COMPRESSION&lt;/span&gt; &lt;span class="n"&gt;ZSTD&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ROW_GROUP_SIZE&lt;/span&gt; &lt;span class="mi"&gt;100000&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now each row group covers a compact patch of ground, its bbox is tight, and a query over one city touches a handful of row groups instead of all of them.&lt;/p&gt;

&lt;p&gt;The important property to internalise: &lt;strong&gt;this is a write-time decision.&lt;/strong&gt; In a database, you can add an index to an existing table whenever you like. Here, the sort order &lt;em&gt;is&lt;/em&gt; the index, and it is baked into the byte layout. You cannot bolt it on afterwards — you rewrite the file. Anyone handing you a GeoParquet file has already decided how fast your spatial filters will be.&lt;/p&gt;




&lt;h2&gt;
  
  
  Cloud-native: the index lives inside the file
&lt;/h2&gt;

&lt;p&gt;Because Parquet's footer sits at a known place and the footer records the byte offset of every column chunk in every row group, a reader can work over &lt;strong&gt;HTTP range requests&lt;/strong&gt;. Fetch the footer. Read the statistics. Decide which chunks you need. Fetch exactly those byte ranges from object storage.&lt;/p&gt;

&lt;p&gt;No server. No database process. No API in front. A file on S3, R2 or Azure Blob is a queryable dataset, and the "index" is not an external structure — it is inside the file.&lt;/p&gt;

&lt;p&gt;That's why &lt;strong&gt;Overture Maps&lt;/strong&gt; distributes the entire planet as GeoParquet on object storage and lets you query a city from a laptop without downloading the world. It is the same philosophy that &lt;strong&gt;COPC&lt;/strong&gt; applies to point clouds: keep the spatial organisation inside a single self-describing file, and let range requests do the rest.&lt;/p&gt;




&lt;h2&gt;
  
  
  The honest limits
&lt;/h2&gt;

&lt;p&gt;GeoParquet is the scan shape. It is genuinely bad at the other job, and pretending otherwise leads to painful architectures.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;No in-place UPDATE.&lt;/strong&gt; Parquet files are immutable. Changing one parcel means rewriting a file (or at best a partition). Correcting one boundary by rewriting a 40 GB file is absurd.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No transactions, no concurrent writers, no table semantics&lt;/strong&gt; — unless you layer &lt;strong&gt;Iceberg&lt;/strong&gt; or &lt;strong&gt;Delta Lake&lt;/strong&gt; on top, which add a metadata layer giving you snapshots, schema evolution and row-level updates over the same Parquet files.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Weak at single-feature lookups.&lt;/strong&gt; Fetching one parcel by ID means scanning row-group statistics for an ID that may be anywhere. A database index does this in microseconds.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Not the best live bbox format.&lt;/strong&gt; For "give me the features in this viewport, right now, over HTTP", &lt;strong&gt;FlatGeobuf&lt;/strong&gt; usually wins: it carries a packed Hilbert R-tree, so a client does a couple of range requests straight to the matching features. GeoParquet's granularity stops at the row group.&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;&lt;strong&gt;PostGIS (row-oriented)&lt;/strong&gt;&lt;/th&gt;
&lt;th&gt;&lt;strong&gt;GeoParquet (columnar)&lt;/strong&gt;&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Physical layout&lt;/td&gt;
&lt;td&gt;Row contiguous on disk&lt;/td&gt;
&lt;td&gt;Column contiguous on disk&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Best at&lt;/td&gt;
&lt;td&gt;Fetch/edit one feature&lt;/td&gt;
&lt;td&gt;Scan/aggregate everything&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Index&lt;/td&gt;
&lt;td&gt;B-tree + R-tree, added anytime&lt;/td&gt;
&lt;td&gt;Sort order, fixed at write time&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Update one feature&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;UPDATE&lt;/code&gt; in place&lt;/td&gt;
&lt;td&gt;Rewrite the file&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Transactions&lt;/td&gt;
&lt;td&gt;Yes (ACID)&lt;/td&gt;
&lt;td&gt;No (unless Iceberg/Delta)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Reads 2 of 40 columns&lt;/td&gt;
&lt;td&gt;Reads all 40&lt;/td&gt;
&lt;td&gt;Reads 2&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Serving model&lt;/td&gt;
&lt;td&gt;Server process + connection&lt;/td&gt;
&lt;td&gt;Plain file + HTTP range requests&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Live viewport fetch&lt;/td&gt;
&lt;td&gt;Good&lt;/td&gt;
&lt;td&gt;Weak (FlatGeobuf better)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Typical home&lt;/td&gt;
&lt;td&gt;Editing systems, APIs, OLTP&lt;/td&gt;
&lt;td&gt;Analytics, distribution, OLAP&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;




&lt;h2&gt;
  
  
  The verdict
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;If you edit it, it belongs in a database. If you only ever scan it, it belongs in a file.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;That is the whole rule, and it is a statement about access pattern, not about technology preference. The surveyor's workload and the analyst's workload are different physical problems, and one byte layout cannot be optimal for both.&lt;/p&gt;

&lt;p&gt;Mature geospatial stacks stop trying and &lt;strong&gt;keep both shapes&lt;/strong&gt;: PostGIS as the system of record where features are created and edited, and a GeoParquet export — Hilbert-sorted, bbox-covered, sitting on object storage — as the analytical and distribution copy, refreshed nightly. The database stays small and fast at the thing it is good at. The analyst stops running six-minute queries against production. Nobody argues about which one "wins", because they were never doing the same job.&lt;/p&gt;




&lt;p&gt;&lt;a href="https://youtube.com/shorts/rMbTMVLjdik" rel="noopener noreferrer"&gt;▶ Watch the reel: your geodata has two shapes&lt;/a&gt;&lt;/p&gt;

</description>
      <category>gis</category>
      <category>programming</category>
      <category>softwareengineering</category>
      <category>database</category>
    </item>
    <item>
      <title>Connection Pooling: Why Your API Dies at 200 Users (But the DB Is at 4% CPU)</title>
      <dc:creator>Vahid Aghajani</dc:creator>
      <pubDate>Sun, 19 Jul 2026 07:19:04 +0000</pubDate>
      <link>https://dev.to/vahid_aghajani_60ce9dbec9/connection-pooling-why-your-api-dies-at-200-users-but-the-db-is-at-4-cpu-3mpc</link>
      <guid>https://dev.to/vahid_aghajani_60ce9dbec9/connection-pooling-why-your-api-dies-at-200-users-but-the-db-is-at-4-cpu-3mpc</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;📺 Prefer to watch? &lt;a href="https://youtu.be/u5Lshf_uX1Q" rel="noopener noreferrer"&gt;90-second YouTube Short&lt;/a&gt; · 💬 &lt;a href="https://t.me/SoftwareEngineerBlog" rel="noopener noreferrer"&gt;Telegram&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://software-engineer-blog.com/content/connection-pooling-why-your-api-dies-at-200-users-but-the-db-is-at-4-cpu?id=122" rel="noopener noreferrer"&gt;software-engineer-blog.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;FitLog is a small workout-tracking app: one API, one Postgres database behind it. At 20 users it's instant — every request feels like a local function call. Then a launch happens, 200 people sign up at once, and the API falls over. Requests time out, error rates spike, pagers go off.&lt;/p&gt;

&lt;p&gt;So you open the database dashboard, bracing for a pegged CPU and a disk on fire — and Postgres is at &lt;strong&gt;4% CPU&lt;/strong&gt;. Barely awake. The database was never the bottleneck. It was never even &lt;em&gt;reached&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;That paradox is the whole reason connection pooling exists.&lt;/p&gt;




&lt;h2&gt;
  
  
  A connection is not a variable — it's a small server
&lt;/h2&gt;

&lt;p&gt;The instinct is to think of "connecting to the database" as cheap: assign a variable, you're connected. It isn't. Opening a fresh Postgres connection is a small negotiation:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;A &lt;strong&gt;TCP handshake&lt;/strong&gt; (round trips).&lt;/li&gt;
&lt;li&gt;A &lt;strong&gt;TLS handshake&lt;/strong&gt; (more round trips, crypto).&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Password authentication.&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;Then Postgres &lt;strong&gt;forks a whole backend process&lt;/strong&gt; dedicated to that one connection — its own memory, its own everything.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Add it up and you're looking at roughly &lt;strong&gt;~40ms and megabytes of RAM&lt;/strong&gt; — to set up a connection that will then run a &lt;strong&gt;3ms query&lt;/strong&gt;. You're paying 90%+ of the cost on setup, before any real work happens.&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="c1"&gt;# The trap: a brand-new connection per request
&lt;/span&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;handle_request&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;query&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;conn&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;psycopg2&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;connect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;DATABASE_URL&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="c1"&gt;# ~40ms: TCP + TLS + auth + forked backend
&lt;/span&gt;    &lt;span class="n"&gt;cur&lt;/span&gt; &lt;span class="o"&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;cursor&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;cur&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="n"&gt;query&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;                     &lt;span class="c1"&gt;# 3ms of actual work
&lt;/span&gt;    &lt;span class="n"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;cur&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fetchall&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;close&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;                           &lt;span class="c1"&gt;# ...and throw the expensive thing away
&lt;/span&gt;    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Do that once per request and every request drags a 40ms anchor behind a 3ms task.&lt;/p&gt;

&lt;h2&gt;
  
  
  The wall: &lt;code&gt;max_connections = 100&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;It gets worse than "slow." Because every connection is a forked backend process, Postgres refuses to spawn an unbounded number of them. It ships with:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight conf"&gt;&lt;code&gt;&lt;span class="n"&gt;max_connections&lt;/span&gt; = &lt;span class="m"&gt;100&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Connection &lt;strong&gt;#101 does not queue, and it does not slow down. It is refused&lt;/strong&gt;, immediately:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;FATAL: sorry, too many clients already
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;So when 200 users arrive at once and your app opens a connection per request, roughly &lt;strong&gt;100 are turned away at the door&lt;/strong&gt;, and the survivors each burn 40ms of setup for their 3ms of work. From the app it looks like the database is melting. From the database's point of view, it did almost nothing — it spent its time forking and rejecting, not querying. Hence 4% CPU while everything is on fire.&lt;/p&gt;




&lt;h2&gt;
  
  
  The fix: stop opening connections
&lt;/h2&gt;

&lt;p&gt;A connection pool flips the model. Instead of creating a connection per request, you open a small, fixed set &lt;strong&gt;once, at boot&lt;/strong&gt;, and keep them open for the life of the process. A request no longer &lt;em&gt;creates&lt;/em&gt; a connection — it &lt;strong&gt;borrows&lt;/strong&gt; one from the pool, runs its query, and &lt;strong&gt;hands it straight back&lt;/strong&gt;, still open, still authenticated.&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="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;psycopg2.pool&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;ThreadedConnectionPool&lt;/span&gt;

&lt;span class="c1"&gt;# Opened ONCE at startup — the expensive setup is paid a single time
&lt;/span&gt;&lt;span class="n"&gt;pool&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;ThreadedConnectionPool&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;minconn&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;maxconn&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;20&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;dsn&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;DATABASE_URL&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;handle_request&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;query&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;conn&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;pool&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getconn&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;          &lt;span class="c1"&gt;# borrow a warm, authenticated slot (microseconds)
&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;cur&lt;/span&gt; &lt;span class="o"&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;cursor&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="n"&gt;cur&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="n"&gt;query&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;         &lt;span class="c1"&gt;# 3ms of actual work
&lt;/span&gt;        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;cur&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fetchall&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="k"&gt;finally&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;pool&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;putconn&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="c1"&gt;# return the slot, still open, for the next request
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The 40ms setup happens &lt;code&gt;maxconn&lt;/code&gt; times over the whole life of the app, not once per request. That's the entire trick — and it's what PgBouncer, HikariCP, and SQLAlchemy's pool are all doing under the hood. One job.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three things people get wrong
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;1. The pool is a queue, not a multiplier.&lt;/strong&gt; A pool of 20 does not let you serve infinite users. When all 20 slots are busy, request #21 &lt;strong&gt;waits for a slot to free up&lt;/strong&gt; — it doesn't fail with "too many clients." You've traded &lt;em&gt;hard refusals&lt;/em&gt; for &lt;em&gt;bounded waiting&lt;/em&gt;, which is almost always the trade you want.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. A smaller pool is often faster.&lt;/strong&gt; It's tempting to crank the pool to 100 "to be safe." But 100 connections fighting over the same CPU cores, locks, and memory bandwidth run &lt;em&gt;slower&lt;/em&gt; than 20 connections running at full speed. Past the point where the pool size matches what the database can actually do in parallel, more connections is pure contention. &lt;strong&gt;20 focused workers beat 100 elbowing each other.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Size it to the database, not to your users.&lt;/strong&gt; The right pool size tracks the database's cores and disk, not how many users you have. And the ceiling is &lt;strong&gt;shared&lt;/strong&gt;: the pool is &lt;em&gt;per process&lt;/em&gt;. Run 10 app instances with a pool of 20 each and you've opened &lt;strong&gt;200 connections&lt;/strong&gt; to a database that allows 100. Do the math across your whole fleet, not per box.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
  &lt;thead&gt;&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Connection per request&lt;/th&gt;
&lt;th&gt;With a pool&lt;/th&gt;
&lt;/tr&gt;&lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
&lt;td&gt;Setup cost&lt;/td&gt;
&lt;td&gt;~40ms on every request&lt;/td&gt;
&lt;td&gt;Paid once, at boot&lt;/td&gt;
&lt;/tr&gt;
    &lt;tr&gt;
&lt;td&gt;Under a burst&lt;/td&gt;
&lt;td&gt;Connection #101 refused&lt;/td&gt;
&lt;td&gt;Request #21 waits in a queue&lt;/td&gt;
&lt;/tr&gt;
    &lt;tr&gt;
&lt;td&gt;DB processes&lt;/td&gt;
&lt;td&gt;One fork per request&lt;/td&gt;
&lt;td&gt;Fixed, reused&lt;/td&gt;
&lt;/tr&gt;
    &lt;tr&gt;
&lt;td&gt;Failure mode&lt;/td&gt;
&lt;td&gt;"too many clients already"&lt;/td&gt;
&lt;td&gt;Bounded latency&lt;/td&gt;
&lt;/tr&gt;
    &lt;tr&gt;
&lt;td&gt;Right size&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;DB cores, shared across all processes&lt;/td&gt;
&lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  The one that bites in production
&lt;/h2&gt;

&lt;p&gt;A pool &lt;strong&gt;hides a burst, not a bug.&lt;/strong&gt; When you see &lt;code&gt;pool exhausted&lt;/code&gt; / &lt;code&gt;QueuePool limit reached&lt;/code&gt;, the reflex is to raise the pool size. Resist it. Nine times out of ten the pool isn't too small — a &lt;strong&gt;slow query is holding slots&lt;/strong&gt;. One query that used to take 3ms now takes 3 seconds (a missing index, a lock, a table scan), so each slot is occupied 1000× longer, and 20 slots drain in an instant. Bumping the pool to 40 just means you exhaust 40 slots a moment later while the real culprit — the slow query — sails on. Fix the query, not the number.&lt;/p&gt;




&lt;h2&gt;
  
  
  The same idea, one layer up: serving LLMs
&lt;/h2&gt;

&lt;p&gt;If you build AI features, you've already met this exact shape twice — and pooling is the answer both times.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;RAG retrieval hammers your database.&lt;/strong&gt; A retrieval-augmented app runs a vector similarity search (pgvector, or a dedicated store) on &lt;em&gt;every&lt;/em&gt; question, often several per user turn. That's a connection-per-request firehose pointed straight at Postgres. Without a pool you rebuild the 40ms handshake on every retrieval; with one, the embedding lookups borrow warm slots. The RAG path is one of the most connection-hungry workloads you can ship, and it's the first place a missing pool shows up as mysterious latency.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;LLM inference servers are connection pools for the GPU.&lt;/strong&gt; Look at how vLLM or TGI serve a model and you'll see the identical mental model. The GPU can only decode so many sequences at once — that's &lt;code&gt;max_num_seqs&lt;/code&gt;, the exact analogue of &lt;code&gt;max_connections&lt;/code&gt;. Incoming requests don't each spin up their own model; they &lt;strong&gt;borrow a decode slot&lt;/strong&gt; via continuous batching, stream their tokens, and release it. Request #N over the limit &lt;strong&gt;waits in a queue&lt;/strong&gt; — it isn't refused. And the ceiling is set by &lt;strong&gt;KV-cache memory (GPU cores and VRAM), not by your user count&lt;/strong&gt; — you size the batch to the hardware, exactly like sizing a pool to the database's cores. Even "pool exhausted = slow query" carries over: when a serving queue backs up, the cause is usually a few very long generations holding slots, not a batch size that's too small. Same disease, same cure, one layer up the stack.&lt;/p&gt;




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

&lt;p&gt;Connection pooling isn't an optimization you bolt on later — for anything with real concurrency it's the difference between "works in the demo" and "survives the launch." Open your connections &lt;strong&gt;once&lt;/strong&gt;, keep them warm, &lt;strong&gt;borrow and return&lt;/strong&gt; instead of create and destroy. Treat the pool as a &lt;strong&gt;queue&lt;/strong&gt; you size to the database (and count across every process), and when it exhausts, go hunt the &lt;strong&gt;slow query&lt;/strong&gt; before you touch the dial.&lt;/p&gt;

&lt;p&gt;No more 3 a.m. &lt;em&gt;too many clients already&lt;/em&gt; pages.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Want the 90-second visual version? &lt;a href="https://youtube.com/shorts/u5Lshf_uX1Q" rel="noopener noreferrer"&gt;Watch the reel.&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;

</description>
      <category>database</category>
      <category>programming</category>
      <category>softwareengineering</category>
      <category>backend</category>
    </item>
    <item>
      <title>Zero-Shot vs Few-Shot Prompting: Why Your LLM Output Keeps Breaking (and the 1-Minute Fix)</title>
      <dc:creator>Vahid Aghajani</dc:creator>
      <pubDate>Sat, 18 Jul 2026 09:49:21 +0000</pubDate>
      <link>https://dev.to/vahid_aghajani_60ce9dbec9/zero-shot-vs-few-shot-prompting-why-your-llm-output-keeps-breaking-and-the-1-minute-fix-p9g</link>
      <guid>https://dev.to/vahid_aghajani_60ce9dbec9/zero-shot-vs-few-shot-prompting-why-your-llm-output-keeps-breaking-and-the-1-minute-fix-p9g</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;📺 Prefer to watch? &lt;a href="https://youtu.be/nslQvA740XM" rel="noopener noreferrer"&gt;90-second YouTube Short&lt;/a&gt; · 💬 &lt;a href="https://t.me/SoftwareEngineerBlog" rel="noopener noreferrer"&gt;Telegram&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://software-engineer-blog.com/content/zero-shot-vs-few-shot-prompting-why-your-llm-output-keeps-breaking-and-the-1-minute-fix?id=121" rel="noopener noreferrer"&gt;software-engineer-blog.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Your model returns clean JSON in the notebook and a rambling paragraph in production. Same model, same question. You changed nothing about the weights—you changed the prompt.&lt;/p&gt;

&lt;p&gt;That difference is zero-shot versus few-shot prompting, and it's the cheapest reliability upgrade in your AI stack.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Mental model:&lt;/strong&gt; A language model is a next-token predictor with no memory of your intent except what's in the current prompt. Your prompt is the only spec it gets. Examples in that prompt teach it what shape you want—without retraining a single weight.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Running Example: A HelpDesk Ticket Classifier
&lt;/h2&gt;

&lt;p&gt;You're building a support system. Every ticket comes in as prose—messy, varied, human. Your backend needs strict JSON:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"category"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"billing"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"priority"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"high"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"action"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"refund"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You have two ways to ask the model. Both use the exact same trained weights. The output shape differs because the prompt changes.&lt;/p&gt;




&lt;h2&gt;
  
  
  Zero-Shot: Ask Without Examples
&lt;/h2&gt;

&lt;p&gt;You pass the model a ticket and a question:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="err"&gt;Ticket:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"My invoice shows $500 but I only used the service for 2 days. This is wrong."&lt;/span&gt;&lt;span class="w"&gt;

&lt;/span&gt;&lt;span class="err"&gt;Convert&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;this&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;support&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;ticket&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;to&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;JSON&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;with&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;keys:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;category,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;priority,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;action.&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The model predicts the next token. Then the next. It has no worked example to anchor its output shape. What you get back might be:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A rambling paragraph starting with "This ticket is clearly about billing..."&lt;/li&gt;
&lt;li&gt;Keys spelled differently: &lt;code&gt;"Category"&lt;/code&gt; instead of &lt;code&gt;"category"&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Extra keys you never asked for&lt;/li&gt;
&lt;li&gt;Values that don't match your enum ("medium-high" instead of "high")&lt;/li&gt;
&lt;li&gt;Sometimes valid JSON, sometimes not&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Each call drifts. The model is guessing the shape because your prompt gave it no pattern to copy.&lt;/p&gt;




&lt;h2&gt;
  
  
  Few-Shot: Paste Worked Examples First
&lt;/h2&gt;

&lt;p&gt;Now you paste 2–3 examples of (ticket → correct JSON) &lt;em&gt;before&lt;/em&gt; the real question:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="err"&gt;Example&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="err"&gt;:&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="err"&gt;Ticket:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"App crashed when I tried to export data. Lost 30 minutes of work."&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="err"&gt;JSON:&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"category"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"technical"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"priority"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"high"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"action"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"investigate"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;

&lt;/span&gt;&lt;span class="err"&gt;Example&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="err"&gt;:&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="err"&gt;Ticket:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Can you walk me through the API docs? I'm confused about authentication."&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="err"&gt;JSON:&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"category"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"support"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"priority"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"low"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"action"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"guide"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;

&lt;/span&gt;&lt;span class="err"&gt;Now&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;classify&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;this&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;ticket:&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="err"&gt;Ticket:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"My invoice shows $500 but I only used the service for 2 days. This is wrong."&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="err"&gt;JSON:&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The model sees the pattern: key names, value choices, JSON shape. When it predicts the next token after &lt;code&gt;JSON:&lt;/code&gt;, it copies that pattern:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"category"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"billing"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"priority"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"high"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"action"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"refund"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Same model. Same weights. The prompt now carries the specification.&lt;/p&gt;




&lt;h2&gt;
  
  
  Why This Isn't Fine-Tuning
&lt;/h2&gt;

&lt;p&gt;A crucial distinction: few-shot &lt;strong&gt;changes no weights&lt;/strong&gt;. Fine-tuning would:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Take your examples&lt;/li&gt;
&lt;li&gt;Recompute gradients through the entire model&lt;/li&gt;
&lt;li&gt;Update weights so the model "remembers" your task&lt;/li&gt;
&lt;li&gt;Leave a new checkpoint on disk&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Few-shot does none of that. The examples live only in the context window—the token buffer—for that single request. Once the request ends, the model is unchanged. This is &lt;strong&gt;in-context learning&lt;/strong&gt;: the model learns a task by reading examples in the same conversation, not by retraining.&lt;/p&gt;

&lt;p&gt;That's why it's fast to deploy (no training loop) and why it costs tokens (examples ride in every prompt).&lt;/p&gt;




&lt;h2&gt;
  
  
  The Honest Tradeoff
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;
      &lt;th&gt;Dimension&lt;/th&gt;
      &lt;th&gt;Zero-Shot&lt;/th&gt;
      &lt;th&gt;Few-Shot&lt;/th&gt;
    &lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
      &lt;td&gt;Setup cost&lt;/td&gt;
      &lt;td&gt;None—just ask&lt;/td&gt;
      &lt;td&gt;Spend time writing 2–3 good examples&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Tokens per request&lt;/td&gt;
      &lt;td&gt;Minimal (prompt + question)&lt;/td&gt;
      &lt;td&gt;Higher (examples + prompt + question)&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Cost per request&lt;/td&gt;
      &lt;td&gt;Cheaper&lt;/td&gt;
      &lt;td&gt;More expensive (more tokens)&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Latency&lt;/td&gt;
      &lt;td&gt;Slightly faster&lt;/td&gt;
      &lt;td&gt;Slightly slower (more tokens to process)&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Output consistency&lt;/td&gt;
      &lt;td&gt;Drifts; shape varies&lt;/td&gt;
      &lt;td&gt;Locked; model copies your pattern&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;When it breaks&lt;/td&gt;
      &lt;td&gt;Tricky tasks; hard to infer intent from wording alone&lt;/td&gt;
      &lt;td&gt;When examples don't cover edge cases&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Every few-shot example you add burns tokens on every request. But if drifting output breaks your downstream code, you have no choice.&lt;/p&gt;




&lt;h2&gt;
  
  
  When to Reach for Each
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Zero-shot:&lt;/strong&gt; The task is obvious from the instruction alone. You're asking for a summary, translation, or simple classification where the expected output format is standard (prose, a sentence). Cost matters more than perfect consistency.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Few-Shot:&lt;/strong&gt; You need a locked output shape (JSON, XML, a specific enum), a tricky judgment call where the edge cases aren't obvious, or a task where the phrasing in your examples changes the model's behavior (e.g., whether it calls something "priority: high" vs "severity: critical"). Consistency matters more than saving a few tokens.&lt;/p&gt;

&lt;p&gt;In the HelpDesk example, you &lt;em&gt;need&lt;/em&gt; few-shot because:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The output must be valid JSON your backend can parse&lt;/li&gt;
&lt;li&gt;Keys and values must match an enum&lt;/li&gt;
&lt;li&gt;The model can't infer that from wording alone; it needs examples to copy&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Zero-shot would save tokens but cost you runtime errors. Few-shot costs tokens but keeps your pipeline running.&lt;/p&gt;




&lt;h2&gt;
  
  
  The LLM Serving Angle
&lt;/h2&gt;

&lt;p&gt;In production, token count drives latency and cost. Few-shot examples increase the &lt;strong&gt;prompt length&lt;/strong&gt;, which means:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Longer prefill time&lt;/strong&gt; (processing the entire prompt, including examples, before generating the first answer token)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Higher tokens-per-second throughput&lt;/strong&gt; (you're paying for more compute upfront)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Bigger memory footprint&lt;/strong&gt; in the context window&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you're serving thousands of concurrent requests, those extra tokens compound. Some teams cache the prompt prefix (the examples) so only the new question and answer are computed per request—reducing redundant work. Others batch zero-shot requests to keep latency flat, and reserve few-shot for high-stakes tasks where consistency justifies the cost.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Bottom Line
&lt;/h2&gt;

&lt;p&gt;Zero-shot asks; few-shot shows. Same model, same weights—in-context learning does the rest. Reach for zero-shot when the task is obvious and cost matters; reach for few-shot when you need a locked format or your task has edge cases examples can clarify.&lt;/p&gt;

&lt;p&gt;Watch the 90-second reel on YouTube or Instagram (@software-engineer-blog) for the visual walkthrough.&lt;/p&gt;

</description>
      <category>promptengineering</category>
      <category>programming</category>
      <category>softwareengineering</category>
    </item>
    <item>
      <title>The N+1 Query Problem: Why 100 Products Cost 101 Queries (and Why an Index Won't Save You)</title>
      <dc:creator>Vahid Aghajani</dc:creator>
      <pubDate>Fri, 17 Jul 2026 17:03:32 +0000</pubDate>
      <link>https://dev.to/vahid_aghajani_60ce9dbec9/the-n1-query-problem-why-100-products-cost-101-queries-and-why-an-index-wont-save-you-1jnp</link>
      <guid>https://dev.to/vahid_aghajani_60ce9dbec9/the-n1-query-problem-why-100-products-cost-101-queries-and-why-an-index-wont-save-you-1jnp</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;📺 Prefer to watch? &lt;a href="https://youtu.be/BXJcQa8vBEs" rel="noopener noreferrer"&gt;90-second YouTube Short&lt;/a&gt; · 💬 &lt;a href="https://t.me/SoftwareEngineerBlog" rel="noopener noreferrer"&gt;Telegram&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://software-engineer-blog.com/content/the-n1-query-problem-why-100-products-cost-101-queries-and-why-an-index-wont-save-you?id=120" rel="noopener noreferrer"&gt;software-engineer-blog.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;You wrote &lt;strong&gt;one&lt;/strong&gt; query. The database ran &lt;strong&gt;101&lt;/strong&gt;. And nothing in the code looks wrong.&lt;/p&gt;

&lt;p&gt;This is the N+1 problem — the single most common reason a page that was instant with 10 rows falls over at 1,000. There is no slow query in the log, nothing flagged, nothing to blame. Just a loop that quietly turns one request into a hundred. Let's build it up from first principles, fix it in the query you already wrote, and then — the part most explanations skip — watch the exact same shape appear far away from any database.&lt;/p&gt;




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

&lt;p&gt;Your page shows 100 products. So you write one query:&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;SELECT&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;products&lt;/span&gt; &lt;span class="k"&gt;LIMIT&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One query. Then you render the list, and for each product you show its category:&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;products&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Product&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;limit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;all&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;   &lt;span class="c1"&gt;# 1 query
&lt;/span&gt;
&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;product&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;products&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;product&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;product&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;category&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;   &lt;span class="c1"&gt;# ← one more query, every loop
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That &lt;code&gt;product.category&lt;/code&gt; looks like a field access. It isn't. The category lives in another table, and it wasn't loaded, so the ORM goes and fetches it — &lt;strong&gt;once per product&lt;/strong&gt;. One hundred products, one hundred extra queries. Plus the original list query, that's &lt;strong&gt;1 + N&lt;/strong&gt;: the N+1 problem, named after exactly this shape.&lt;/p&gt;

&lt;p&gt;The trap is that the code reads perfectly. There is no visible loop over the database, no obvious mistake. The extra hundred round-trips are hidden inside an attribute access.&lt;/p&gt;




&lt;h2&gt;
  
  
  The floor nobody starts with: a query's cost is the round-trip
&lt;/h2&gt;

&lt;p&gt;To see why 101 queries is a disaster when each one is "fast," you have to know what one query actually costs.&lt;/p&gt;

&lt;p&gt;Ask Postgres to find a single category by its primary key and it does that in about &lt;strong&gt;0.18 ms&lt;/strong&gt;. The lookup is genuinely fast. But &lt;em&gt;getting the question to the database and the answer back&lt;/em&gt; — the network round-trip — costs around &lt;strong&gt;2 ms&lt;/strong&gt;. Serialize the query, cross the socket, wait, deserialize the result.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;The lookup is not the bill. The &lt;strong&gt;trip&lt;/strong&gt; is the bill.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;So each of those 100 category fetches is a perfectly indexed, sub-millisecond lookup wrapped in a 2 ms round-trip. Multiply out: ~100 trips × ~2 ms ≈ &lt;strong&gt;512 ms&lt;/strong&gt; on that page, versus &lt;strong&gt;~12 ms&lt;/strong&gt; if you'd asked once. Same data. 40× slower, purely in trips.&lt;/p&gt;




&lt;h2&gt;
  
  
  Why an index won't save you
&lt;/h2&gt;

&lt;p&gt;The instinct, when a page is slow, is to reach for an index. Run &lt;code&gt;EXPLAIN&lt;/code&gt; and you'll be disappointed: the database is &lt;strong&gt;already&lt;/strong&gt; using the primary-key index for every one of those category lookups. They are as fast as a single query can be.&lt;/p&gt;

&lt;p&gt;That's the whole point, and it's worth stating plainly:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;An &lt;strong&gt;index&lt;/strong&gt; decides how fast &lt;strong&gt;ONE&lt;/strong&gt; query finds its rows. &lt;strong&gt;N+1&lt;/strong&gt; decides &lt;strong&gt;how many&lt;/strong&gt; queries you issue at all.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Those are two different problems. An index cannot change a number it doesn't control. 101 indexed queries are still 101 round-trips. You can index every column in the schema and this page stays slow, because the cost was never in the finding — it was in the trips.&lt;/p&gt;




&lt;h2&gt;
  
  
  The fix goes into the query you already wrote
&lt;/h2&gt;

&lt;p&gt;You don't need caching, a queue, or a rewrite. The fix goes right into the query you already have: add a &lt;strong&gt;&lt;code&gt;JOIN&lt;/code&gt;&lt;/strong&gt;, and the category rides back in the same result, on &lt;strong&gt;one&lt;/strong&gt; trip.&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;SELECT&lt;/span&gt; &lt;span class="n"&gt;products&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;categories&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;category_name&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;products&lt;/span&gt;
&lt;span class="k"&gt;JOIN&lt;/span&gt; &lt;span class="n"&gt;categories&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;categories&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;products&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;category_id&lt;/span&gt;
&lt;span class="k"&gt;LIMIT&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One query. The category is already attached to each row — no per-item lookup, no loop firing behind your back. &lt;strong&gt;101 queries collapse to 1. ~512 ms becomes ~12 ms.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;And you rarely write that SQL by hand. Tell your ORM to &lt;strong&gt;eager-load&lt;/strong&gt; the relationship and it writes the exact join for you — it's one line:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
  &lt;thead&gt;&lt;tr&gt;
&lt;th&gt;ORM&lt;/th&gt;
&lt;th&gt;Eager-load in one line&lt;/th&gt;
&lt;/tr&gt;&lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
&lt;td&gt;SQLAlchemy&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;joinedload(...)&lt;/code&gt; / &lt;code&gt;selectinload(...)&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
    &lt;tr&gt;
&lt;td&gt;Django&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;select_related(...)&lt;/code&gt; / &lt;code&gt;prefetch_related(...)&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
    &lt;tr&gt;
&lt;td&gt;Rails&lt;/td&gt;
&lt;td&gt;&lt;code&gt;includes(...)&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
    &lt;tr&gt;
&lt;td&gt;Prisma&lt;/td&gt;
&lt;td&gt;&lt;code&gt;include: {...}&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# SQLAlchemy — one line turns 101 queries into 1
&lt;/span&gt;&lt;span class="n"&gt;products&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Product&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
      &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;options&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;joinedload&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Product&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;category&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
      &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;limit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
      &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;all&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;h2&gt;
  
  
  It was never about databases
&lt;/h2&gt;

&lt;p&gt;Here is the part worth carrying out of this article, because it's bigger than SQL: &lt;strong&gt;N+1 is not an ORM quirk. It's any loop that makes one round-trip per item.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The database is just where most people notice it first. The same shape shows up everywhere a loop talks to something across a boundary:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;One &lt;strong&gt;REST call&lt;/strong&gt; per row in a list.&lt;/li&gt;
&lt;li&gt;One &lt;strong&gt;GraphQL resolver&lt;/strong&gt; firing per node in a result.&lt;/li&gt;
&lt;li&gt;One &lt;strong&gt;object-store GET&lt;/strong&gt; per key.&lt;/li&gt;
&lt;li&gt;One &lt;strong&gt;LLM call&lt;/strong&gt; awaited at a time.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That last one is where this bites hardest in AI work today. Say you need to classify, embed, or summarize 500 items and you write the obvious loop:&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="c1"&gt;# The N+1 shape, wearing an AI hat
&lt;/span&gt;&lt;span class="n"&gt;results&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;
&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;item&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;items&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;                     &lt;span class="c1"&gt;# 500 items
&lt;/span&gt;    &lt;span class="n"&gt;results&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;llm&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;complete&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;item&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;   &lt;span class="c1"&gt;# one round-trip each, awaited in turn
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every &lt;code&gt;await&lt;/code&gt; waits for a full network round-trip to the model provider before the next one starts. At ~2 seconds each, 500 sequential calls is &lt;strong&gt;~17 minutes&lt;/strong&gt; of near-pure waiting — for work that could have gone out &lt;strong&gt;concurrently&lt;/strong&gt;. The fix is the same idea as the JOIN: stop issuing one trip per item. Batch the inputs into a single request where the API supports it, or fan the calls out with &lt;code&gt;asyncio.gather&lt;/code&gt; / a bounded worker pool and let them fly in parallel. Same disease, same cure — collapse N trips toward 1.&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="c1"&gt;# Fan out instead of awaiting one at a time
&lt;/span&gt;&lt;span class="n"&gt;results&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;asyncio&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;gather&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;llm&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;complete&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;item&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;item&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;items&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Whether the "trip" is a SQL round-trip, an HTTP call, or a token-generation request to a model, the lesson holds: &lt;strong&gt;it's the number of trips, not the speed of each, that's killing you.&lt;/strong&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  Not a toy problem: Shopify
&lt;/h2&gt;

&lt;p&gt;If this sounds like a beginner mistake, it isn't. Shopify hit exactly this shape in their GraphQL API. In their own words:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;"if there were fifty authors, then it would make fifty-one round trips for all the data."&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That's N+1, verbatim, at one of the largest commerce platforms on the internet. Their answer was to build &lt;a href="https://shopify.engineering/solving-the-n-1-problem-for-graphql-through-batching" rel="noopener noreferrer"&gt;&lt;code&gt;graphql-batch&lt;/code&gt;&lt;/a&gt; — a library whose entire job is to coalesce those per-item trips into batched ones. When a company at that scale ships a library just to stop N+1, that tells you how common and how invisible it is.&lt;/p&gt;




&lt;h2&gt;
  
  
  The verdict
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
  &lt;thead&gt;&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Add an index&lt;/th&gt;
&lt;th&gt;Add a JOIN / eager-load&lt;/th&gt;
&lt;/tr&gt;&lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
&lt;td&gt;What it changes&lt;/td&gt;
&lt;td&gt;How fast ONE query finds rows&lt;/td&gt;
&lt;td&gt;How MANY queries you issue&lt;/td&gt;
&lt;/tr&gt;
    &lt;tr&gt;
&lt;td&gt;Round-trips for 100 products&lt;/td&gt;
&lt;td&gt;Still 101&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;/tr&gt;
    &lt;tr&gt;
&lt;td&gt;Fixes N+1?&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
    &lt;tr&gt;
&lt;td&gt;Where it lives&lt;/td&gt;
&lt;td&gt;Schema / migration&lt;/td&gt;
&lt;td&gt;The query you already wrote&lt;/td&gt;
&lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The real takeaway is a habit, not a keyword: &lt;strong&gt;log the round-trip count per request and assert on it in a test.&lt;/strong&gt; If that number grows when your data grows, you have an N+1 — and it is completely invisible on a laptop seeded with 10 rows, which is precisely why it survives all the way to production.&lt;/p&gt;

&lt;p&gt;Count your round trips, not your milliseconds.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Want the 90-second visual version? &lt;a href="https://youtube.com/shorts/BXJcQa8vBEs" rel="noopener noreferrer"&gt;Watch the reel.&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;

</description>
      <category>database</category>
      <category>programming</category>
      <category>softwareengineering</category>
      <category>performance</category>
    </item>
    <item>
      <title>How a Database Index Actually Works: B-Trees, Seq Scans, and the Cost Nobody Mentions</title>
      <dc:creator>Vahid Aghajani</dc:creator>
      <pubDate>Thu, 16 Jul 2026 21:24:09 +0000</pubDate>
      <link>https://dev.to/vahid_aghajani_60ce9dbec9/how-a-database-index-actually-works-b-trees-seq-scans-and-the-cost-nobody-mentions-528j</link>
      <guid>https://dev.to/vahid_aghajani_60ce9dbec9/how-a-database-index-actually-works-b-trees-seq-scans-and-the-cost-nobody-mentions-528j</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;📺 Prefer to watch? &lt;a href="https://youtu.be/ek97Z_cUhx4" rel="noopener noreferrer"&gt;90-second YouTube Short&lt;/a&gt; · 💬 &lt;a href="https://t.me/SoftwareEngineerBlog" rel="noopener noreferrer"&gt;Telegram&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://software-engineer-blog.com/content/how-a-database-index-actually-works-b-trees-seq-scans-and-the-cost-nobody-mentions?id=119" rel="noopener noreferrer"&gt;software-engineer-blog.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The same query. &lt;strong&gt;4.2 seconds&lt;/strong&gt;, then &lt;strong&gt;3 milliseconds&lt;/strong&gt;. Same data, same machine, same SQL. The only thing that changed was one line:&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;CREATE&lt;/span&gt; &lt;span class="k"&gt;INDEX&lt;/span&gt; &lt;span class="n"&gt;idx_customers_email&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;customers&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;email&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Most explanations of indexing start at &lt;em&gt;"it's like the index in a book"&lt;/em&gt; — and then stop. That analogy is fine as far as it goes, but it skips the part that actually matters: &lt;strong&gt;why&lt;/strong&gt; the database is slow without one, and &lt;strong&gt;what it costs you&lt;/strong&gt; to add one. So let's start a level below the index.&lt;/p&gt;

&lt;p&gt;Throughout, one running example: &lt;strong&gt;BrewBox&lt;/strong&gt;, a coffee shop with a &lt;code&gt;customers&lt;/code&gt; table of 5,000,000 rows, and one query their login page runs on every single page load:&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;SELECT&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;customers&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;email&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'sara@mail.com'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  First principles: what a table actually is
&lt;/h2&gt;

&lt;p&gt;Here's the thing nobody tells you up front. &lt;strong&gt;A table is a pile of rows on disk, written one after another in the order they arrived.&lt;/strong&gt; That's it. Nothing about it is sorted.&lt;/p&gt;

&lt;p&gt;Sara signed up 3 million rows ago, sandwiched between whoever signed up just before and just after her. Her row isn't in an alphabetical slot. There is no alphabetical slot. There's just insert order.&lt;/p&gt;

&lt;p&gt;So when you ask for &lt;code&gt;WHERE email = 'sara@mail.com'&lt;/code&gt;, the database genuinely &lt;strong&gt;does not know where that row is&lt;/strong&gt;. And it has no clever way to guess, because nothing about the layout of the file correlates with email addresses.&lt;/p&gt;

&lt;h2&gt;
  
  
  The full table scan
&lt;/h2&gt;

&lt;p&gt;With no idea where Sara is, the database has exactly one option: &lt;strong&gt;look everywhere&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Row 1 — not Sara. Row 2 — not Sara. Row 3 — not Sara. All the way down. Five million reads, to return one row. That's a &lt;strong&gt;full table scan&lt;/strong&gt; (Postgres calls it a &lt;code&gt;Seq Scan&lt;/code&gt;), and it's not the database being dumb — it's the database having no alternative.&lt;/p&gt;

&lt;p&gt;And it's worse than a one-time 4.2-second hit, because this is a login page. &lt;strong&gt;Every page load does the whole thing again.&lt;/strong&gt; Ten users online means ten full scans of five million rows, concurrently, competing for the same disk.&lt;/p&gt;




&lt;h2&gt;
  
  
  Enter the index
&lt;/h2&gt;

&lt;p&gt;Now the one line of SQL:&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;CREATE&lt;/span&gt; &lt;span class="k"&gt;INDEX&lt;/span&gt; &lt;span class="n"&gt;idx_customers_email&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;customers&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;email&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Notice what this &lt;em&gt;doesn't&lt;/em&gt; do. It doesn't change your table. It doesn't change your query — you still write the same &lt;code&gt;SELECT&lt;/code&gt;, and the planner just quietly starts using the index. Nothing in your application code changes.&lt;/p&gt;

&lt;p&gt;What it builds is a &lt;strong&gt;second structure, on the side&lt;/strong&gt;. And that structure holds only two things per row:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;the &lt;strong&gt;indexed column&lt;/strong&gt; (the email), and&lt;/li&gt;
&lt;li&gt;a &lt;strong&gt;pointer&lt;/strong&gt; to where that full row physically lives on disk.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Not the whole row. Just the value and the address. And critically — it is kept &lt;strong&gt;sorted&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;That's the entire trick, and it's worth saying plainly: &lt;strong&gt;sorted means you can skip.&lt;/strong&gt; Once the values are in order, you no longer have to look at something to rule it out. You can rule out half the remaining data with a single comparison.&lt;/p&gt;

&lt;h2&gt;
  
  
  The B-tree: three hops, not five million reads
&lt;/h2&gt;

&lt;p&gt;Concretely, that sorted structure is a &lt;strong&gt;B-tree&lt;/strong&gt;. Searching it is the game of "higher or lower," and every guess throws away most of what's left:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Is &lt;code&gt;sara@mail.com&lt;/code&gt; before or after &lt;strong&gt;"m"&lt;/strong&gt;? After → go right. &lt;em&gt;(Half the index just disappeared.)&lt;/em&gt;
&lt;/li&gt;
&lt;li&gt;Before or after &lt;strong&gt;"sar"&lt;/strong&gt;? Take that branch.&lt;/li&gt;
&lt;li&gt;Three hops down, and you're on the exact entry — holding a pointer straight to Sara's row.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The reason this scales so absurdly well is that a B-tree is &lt;strong&gt;wide, not deep&lt;/strong&gt;. Each node holds &lt;em&gt;hundreds&lt;/em&gt; of keys, so the tree fans out fast. Five million rows is only about &lt;strong&gt;three levels deep&lt;/strong&gt;. Ten times the data doesn't cost ten times the work — it costs roughly one more hop.&lt;/p&gt;

&lt;h3&gt;
  
  
  The payoff
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;3 rows read instead of 5,000,000.&lt;/strong&gt; 4.2 seconds → 3 milliseconds. Roughly &lt;strong&gt;1,400× faster&lt;/strong&gt;, with the same query, the same data, and the same machine.&lt;/p&gt;




&lt;h2&gt;
  
  
  The cost nobody mentions
&lt;/h2&gt;

&lt;p&gt;Here's the part that gets left out of every "just add an index" answer.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;An index is a copy.&lt;/strong&gt; It's a real structure that lives on disk, so it costs disk. That's the small cost.&lt;/p&gt;

&lt;p&gt;The real cost is this: &lt;strong&gt;every write has to keep it true.&lt;/strong&gt; Insert one customer and the database doesn't do one write — it writes the row, and then it also updates &lt;strong&gt;every index on that table&lt;/strong&gt;, because an index that doesn't know about Sara is an index that lies.&lt;/p&gt;

&lt;p&gt;Six indexes on &lt;code&gt;customers&lt;/code&gt;? &lt;strong&gt;One INSERT becomes seven writes.&lt;/strong&gt; Same for updates and deletes. Index every column "just in case," and your reads get fast while your writes quietly crawl.&lt;/p&gt;

&lt;p&gt;So an index is not free speed. &lt;strong&gt;It's a trade: faster reads, slower writes.&lt;/strong&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
  &lt;thead&gt;&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;No index&lt;/th&gt;
&lt;th&gt;With an index&lt;/th&gt;
&lt;/tr&gt;&lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
&lt;td&gt;Lookup by that column&lt;/td&gt;
&lt;td&gt;Full scan — reads every row&lt;/td&gt;
&lt;td&gt;~3 hops down a B-tree&lt;/td&gt;
&lt;/tr&gt;
    &lt;tr&gt;
&lt;td&gt;Rows read to find one&lt;/td&gt;
&lt;td&gt;5,000,000&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;/tr&gt;
    &lt;tr&gt;
&lt;td&gt;Query plan&lt;/td&gt;
&lt;td&gt;&lt;code&gt;Seq Scan&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;Index Scan&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
    &lt;tr&gt;
&lt;td&gt;Scaling&lt;/td&gt;
&lt;td&gt;Linear — 10× rows, 10× work&lt;/td&gt;
&lt;td&gt;Logarithmic — 10× rows, ~1 more hop&lt;/td&gt;
&lt;/tr&gt;
    &lt;tr&gt;
&lt;td&gt;Cost of one INSERT&lt;/td&gt;
&lt;td&gt;1 write&lt;/td&gt;
&lt;td&gt;1 write + 1 per index&lt;/td&gt;
&lt;/tr&gt;
    &lt;tr&gt;
&lt;td&gt;Disk&lt;/td&gt;
&lt;td&gt;Just the table&lt;/td&gt;
&lt;td&gt;Table + a copy of the column&lt;/td&gt;
&lt;/tr&gt;
    &lt;tr&gt;
&lt;td&gt;Best for&lt;/td&gt;
&lt;td&gt;Write-heavy tables, tiny tables&lt;/td&gt;
&lt;td&gt;Columns you filter, join, or sort on&lt;/td&gt;
&lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  The verdict: what to index, and what not to
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Index the columns you actually filter, join, or sort on&lt;/strong&gt; — the ones in your &lt;code&gt;WHERE&lt;/code&gt;, your &lt;code&gt;JOIN ... ON&lt;/code&gt;, your &lt;code&gt;ORDER BY&lt;/code&gt;. Those are the columns where a sorted side-structure buys you something real.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Don't index everything else.&lt;/strong&gt; An index nobody queries is pure cost: disk you pay for and writes you slow down, forever, in exchange for nothing. A few good indexes beat a dozen speculative ones.&lt;/p&gt;

&lt;p&gt;Two practical notes:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Tiny tables don't need indexes.&lt;/strong&gt; If the whole table fits in a page or two, scanning it &lt;em&gt;is&lt;/em&gt; the fast path — the planner will often ignore your index anyway, and it's right to.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Low-cardinality columns are usually a bad fit.&lt;/strong&gt; An index on a boolean &lt;code&gt;is_active&lt;/code&gt; can't skip much — half the table matches either way. Indexes pay off when the value is selective enough to eliminate most rows.&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  The same idea, one layer up: indexes in AI systems
&lt;/h2&gt;

&lt;p&gt;If you work on LLM or RAG systems, you've already met this exact trade — just wearing a different hat.&lt;/p&gt;

&lt;p&gt;When a RAG pipeline retrieves context, it's answering "which of my 5,000,000 chunks are closest to this query embedding?" The naive answer is the same as BrewBox's: &lt;strong&gt;compare against all of them&lt;/strong&gt; — a full scan, just with cosine similarity instead of &lt;code&gt;=&lt;/code&gt;. It works fine at ten thousand vectors and falls over at ten million, for precisely the reason above: linear scaling.&lt;/p&gt;

&lt;p&gt;So vector databases do the same thing a relational database does: &lt;strong&gt;build a sorted-ish side-structure that lets you skip.&lt;/strong&gt; A B-tree can't do it (embeddings have no meaningful "before m"), so the structure is different — typically &lt;strong&gt;HNSW&lt;/strong&gt;, a navigable graph you greedily hop through, which I break down in &lt;a href="https://dev.to/content/vector-search-how-hnsw-finds-nearest-neighbours?id=118"&gt;Vector Search — how HNSW finds nearest neighbours&lt;/a&gt;. But the shape of the deal is identical:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Same payoff:&lt;/strong&gt; don't touch most of the data. Hops, not a scan.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Same cost:&lt;/strong&gt; the index is a copy that costs memory, and &lt;strong&gt;every insert has to update it&lt;/strong&gt; — which is exactly why re-embedding a large corpus is slow, and why some vector stores make you rebuild rather than insert cheaply.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Same discipline:&lt;/strong&gt; index the thing you actually query on.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The one extra wrinkle: a B-tree lookup is &lt;strong&gt;exact&lt;/strong&gt;, while a vector index is &lt;strong&gt;approximate&lt;/strong&gt; — HNSW may miss a true nearest neighbor, and you tune that recall/latency trade knowingly. Beyond that, if you understand why &lt;code&gt;CREATE INDEX&lt;/code&gt; makes reads fast and writes slow, you already understand why your vector store behaves the way it does. It's the same bargain: &lt;strong&gt;pay on write, save on read.&lt;/strong&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  Where your 4.2 seconds went
&lt;/h2&gt;

&lt;p&gt;Here's the one thing to actually do with this. Take your slowest endpoint, grab the query it runs, and put &lt;code&gt;EXPLAIN&lt;/code&gt; in front of it:&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;EXPLAIN&lt;/span&gt; &lt;span class="k"&gt;ANALYZE&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="n"&gt;customers&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;email&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'sara@mail.com'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Read the plan. If you see &lt;strong&gt;&lt;code&gt;Seq Scan&lt;/code&gt;&lt;/strong&gt; on a big table, that's not a mystery anymore — that's the database telling you, in plain language, that it's reading every row because you never gave it another option. You just found your 4.2 seconds.&lt;/p&gt;

&lt;p&gt;And if you see &lt;code&gt;Index Scan&lt;/code&gt;, the index is doing its job. The remaining question is the other half of the trade: &lt;em&gt;are you paying for any indexes nobody reads?&lt;/em&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Want the visual version? &lt;a href="https://youtube.com/shorts/ek97Z_cUhx4" rel="noopener noreferrer"&gt;Watch the reel.&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;

</description>
      <category>database</category>
      <category>sql</category>
      <category>programming</category>
      <category>softwareengineering</category>
    </item>
    <item>
      <title>Vector Search — how HNSW finds nearest neighbours</title>
      <dc:creator>Vahid Aghajani</dc:creator>
      <pubDate>Thu, 16 Jul 2026 13:00:10 +0000</pubDate>
      <link>https://dev.to/vahid_aghajani_60ce9dbec9/vector-search-how-hnsw-finds-nearest-neighbours-368</link>
      <guid>https://dev.to/vahid_aghajani_60ce9dbec9/vector-search-how-hnsw-finds-nearest-neighbours-368</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;📺 Prefer to watch? &lt;a href="https://youtu.be/3iRzHUzL9ck" rel="noopener noreferrer"&gt;90-second YouTube Short&lt;/a&gt; · 💬 &lt;a href="https://t.me/SoftwareEngineerBlog" rel="noopener noreferrer"&gt;Telegram&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://software-engineer-blog.com/content/vector-search-how-hnsw-finds-nearest-neighbours?id=118" rel="noopener noreferrer"&gt;software-engineer-blog.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Your documentation site has 2,000,000 help articles. A user types a question into the search box and gets the best match in 2 milliseconds—without the system ever comparing that question to almost any of them. This is not magic. It's HNSW: Hierarchical Navigable Small World, the graph structure that powers vector search in FAISS, pgvector, Qdrant, Weaviate, and Milvus.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Mental model:&lt;/strong&gt; HNSW is a multi-layer graph where every vector points to its nearest neighbors, and search is a greedy walk that descends from sparse long-range links at the top to dense fine-grained links at the bottom—like taking highways down to local streets to find an address.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Problem: Why Brute Force Is Correct but Unusable
&lt;/h2&gt;

&lt;p&gt;You already have the pieces:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Each of your 2,000,000 help documents is embedded—a point in a 768-dimensional space.&lt;/li&gt;
&lt;li&gt;A user's question becomes a point in that same space.&lt;/li&gt;
&lt;li&gt;"Best answer" means the nearest point in that space (highest cosine similarity).&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The obvious solution is also the correct one: compare the query point against all 2,000,000 document points, measure distance to each, return the closest. With 768 dimensions per vector, that's roughly 1,540,000 arithmetic operations per document, summing to about 1,240 milliseconds on a single core.&lt;/p&gt;

&lt;p&gt;Worse: it scales linearly. Double your documents, double your wait. Triple them, triple the wait. At 10 million documents, you are looking at 6+ seconds. At 100 million, a minute or more. And every new search pays the full price.&lt;/p&gt;




&lt;h2&gt;
  
  
  Why You Cannot Index Your Way Out
&lt;/h2&gt;

&lt;p&gt;Your first instinct: &lt;em&gt;Can't we just use a B-tree?&lt;/em&gt; B-trees are brilliant for one-dimensional sorting. They let you find the number 42 in a billion-element list in log(n) comparisons.&lt;/p&gt;

&lt;p&gt;But "nearest neighbor" in 768 dimensions is not sorting on one axis. Closeness is simultaneous proximity across all 768 axes. A B-tree sorts on one—your longitude, your latitude, your price. The second you introduce a second dimension, tree structures collapse because &lt;em&gt;there is no left-right order that preserves nearness in 2D, let alone 768D&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;Some databases (PostgreSQL with pgvector) do attempt tree-based approaches like IVFFlat (Inverted File with flat clusters), but they still scan multiple clusters and fall back to brute force within each. They are faster than pure brute force, but still O(n) in the worst case.&lt;/p&gt;

&lt;p&gt;You need a different shape: a graph.&lt;/p&gt;




&lt;h2&gt;
  
  
  Enter HNSW: The Navigable Small World
&lt;/h2&gt;

&lt;p&gt;HNSW solves this by building a graph where:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Every vector is a node.&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Every node is wired to M of its nearest neighbors&lt;/strong&gt; (M is typically 5–48; higher M = more accuracy, more memory).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Search is a greedy walk:&lt;/strong&gt; starting from a random node (or a designated entry point), hop to whichever neighbor is closer to the query. Repeat until no neighbor is closer—you have arrived.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;This is &lt;em&gt;approximate&lt;/em&gt; search: you may not find the true global nearest neighbor. Instead, you find a &lt;em&gt;local&lt;/em&gt; nearest neighbor—the best one reachable by greedily following edges. But because every node is connected to its nearby neighbors, and those neighbors connect to their neighbors, you can usually reach the true global nearest in a few hops.&lt;/p&gt;

&lt;p&gt;On the AskDocs example: instead of 2,000,000 comparisons, you make roughly 1,800 comparisons across maybe 15–20 hops. That's 0.09% of the work. Latency drops from 1,240 ms to 2 ms.&lt;/p&gt;




&lt;h2&gt;
  
  
  The H: Hierarchy and Layer Skip
&lt;/h2&gt;

&lt;p&gt;One flat graph with M neighbors per node has a problem: a greedy walk might need hundreds of hops to cross the graph. Start at one end, need to reach the other; your neighbors are all locally nearby, so you take tiny steps.&lt;/p&gt;

&lt;p&gt;HNSW solves this with &lt;em&gt;layers&lt;/em&gt;—a skip-list-like idea:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Layer L (top, sparse):&lt;/strong&gt; Contains only a fraction of vectors, wired with long-range links. Think highways connecting cities 500 km apart.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Layer L-1 (denser):&lt;/strong&gt; More vectors, tighter links. Main roads connecting towns 50 km apart.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Layer 0 (bottom, densest):&lt;/strong&gt; Every single vector, linked to its M nearest neighbors. Local streets.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Search descends:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Start at the entry point on the top layer.&lt;/li&gt;
&lt;li&gt;Greedily walk until no neighbor is closer.&lt;/li&gt;
&lt;li&gt;Drop to the next layer and walk again from your current position.&lt;/li&gt;
&lt;li&gt;Repeat until layer 0.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;On layer 0, you refine the answer among nearby vectors. The higher layers got you in the right neighborhood fast; the lower layers get you to the exact street.&lt;/p&gt;

&lt;p&gt;Result: 15–20 hops instead of hundreds. And the number of hops is &lt;em&gt;logarithmic&lt;/em&gt; in the dataset size.&lt;/p&gt;




&lt;h2&gt;
  
  
  Two Tuning Knobs: M and ef_search
&lt;/h2&gt;

&lt;p&gt;HNSW has two main hyperparameters:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;M:&lt;/strong&gt; Edges per node (default ~16). Higher M = faster search but more memory and slower insertions.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;ef_search:&lt;/strong&gt; The size of a candidate list kept during search. The algorithm explores the graph, keeping the N best candidates seen so far. Higher ef_search = more of the graph explored = higher recall but slower search.&lt;/p&gt;

&lt;p&gt;You set ef_search &lt;em&gt;at query time&lt;/em&gt;, not build time. This lets you tune recall vs. latency per query. A strict recall requirement? Raise ef_search. A latency deadline? Lower it.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Catch: Approximation Is a Tradeoff
&lt;/h2&gt;

&lt;p&gt;HNSW is &lt;em&gt;approximate&lt;/em&gt;, not exact. A greedy walk can settle into a local minimum—the best neighbor you can reach from your current position, but not the global best. This is especially likely in sparse regions of the embedding space or when M is small.&lt;/p&gt;

&lt;p&gt;Accuracy is measured as &lt;strong&gt;recall:&lt;/strong&gt; the fraction of true nearest neighbors found. A recall of 0.99 means 99% of your top-10 results would have appeared in a brute-force top-10.&lt;/p&gt;

&lt;p&gt;Recall is tunable:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Raise M → more edges per node → more paths to the true nearest → higher recall, higher memory, slower build.&lt;/li&gt;
&lt;li&gt;Raise ef_search → larger candidate set during search → more graph explored → higher recall, slower query.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;You cannot have perfect recall at perfect speed. You choose your point on the recall-latency curve. Most production systems run at 95–99% recall to stay sub-10ms; some (e.g., recommendation systems) drop to 90% recall and trade 0.5ms latency for a small recall penalty.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Payoff
&lt;/h2&gt;

&lt;p&gt;Brute force on 2,000,000 vectors:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;2,000,000 comparisons&lt;/li&gt;
&lt;li&gt;~1,240 ms&lt;/li&gt;
&lt;li&gt;100% recall&lt;/li&gt;
&lt;li&gt;O(n) scaling&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;HNSW with M=16, ef_search=200:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;~1,800 comparisons&lt;/li&gt;
&lt;li&gt;~2 ms&lt;/li&gt;
&lt;li&gt;~99% recall&lt;/li&gt;
&lt;li&gt;O(log n) scaling&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;You trade 1% accuracy for a 600× speedup. In practice, that 1% is almost never noticed by users—the wrong answer is so close in meaning space that it is functionally equivalent.&lt;/p&gt;




&lt;h2&gt;
  
  
  One Table: HNSW vs. Alternatives
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;
      &lt;th&gt;Approach&lt;/th&gt;
      &lt;th&gt;Lookups (2M vectors)&lt;/th&gt;
      &lt;th&gt;Latency&lt;/th&gt;
      &lt;th&gt;Recall&lt;/th&gt;
      &lt;th&gt;Scaling&lt;/th&gt;
      &lt;th&gt;Memory Overhead&lt;/th&gt;
    &lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
      &lt;td&gt;Brute Force&lt;/td&gt;
      &lt;td&gt;2,000,000&lt;/td&gt;
      &lt;td&gt;~1,240 ms&lt;/td&gt;
      &lt;td&gt;100%&lt;/td&gt;
      &lt;td&gt;O(n)&lt;/td&gt;
      &lt;td&gt;Minimal&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;IVFFlat (clustering)&lt;/td&gt;
      &lt;td&gt;~100,000&lt;/td&gt;
      &lt;td&gt;~60 ms&lt;/td&gt;
      &lt;td&gt;~98%&lt;/td&gt;
      &lt;td&gt;O(n) worst-case&lt;/td&gt;
      &lt;td&gt;Low&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;HNSW (hierarchical graph)&lt;/td&gt;
      &lt;td&gt;~1,800&lt;/td&gt;
      &lt;td&gt;~2 ms&lt;/td&gt;
      &lt;td&gt;~99%&lt;/td&gt;
      &lt;td&gt;O(log n)&lt;/td&gt;
      &lt;td&gt;Moderate (M × n edges)&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;




&lt;h2&gt;
  
  
  HNSW for LLM Inference and RAG
&lt;/h2&gt;

&lt;p&gt;In Retrieval-Augmented Generation (RAG), you embed a user's prompt and search a knowledge base to fetch relevant context before passing it to an LLM. Latency here is &lt;strong&gt;TTFT&lt;/strong&gt; (time-to-first-token): every millisecond spent searching is a millisecond the user waits before the model starts generating.&lt;/p&gt;

&lt;p&gt;HNSW is essential because:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Vector databases store millions of chunks&lt;/strong&gt; (customer docs, code, research papers). Brute-force search would add 1+ seconds per query—unacceptable for interactive LLM chat.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;HNSW keeps TTFT under 10ms&lt;/strong&gt;, letting the bottleneck shift to the LLM's token generation (TPOT, time-per-output-token), not retrieval.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Approximate recall is fine here.&lt;/strong&gt; An LLM is robust to slightly off-topic context; 99% recall is indistinguishable from 100% in practice.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;When building a RAG pipeline, choose HNSW (via FAISS, Qdrant, Weaviate, or pgvector) over brute-force search; your TTFT will stay latency-bound by the LLM, not the retriever.&lt;/p&gt;




&lt;h2&gt;
  
  
  Tools That Use HNSW
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;FAISS&lt;/strong&gt; (Meta, open-source): low-level vector search library; HNSW is one of many indices.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;hnswlib&lt;/strong&gt; (Yu. Malkov, open-source): the reference HNSW implementation; often embedded in other databases.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;pgvector&lt;/strong&gt; (Postgres extension): HNSW available via &lt;code&gt;CREATE INDEX ... USING hnsw&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Qdrant&lt;/strong&gt; (vector database): HNSW is the default index type.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Weaviate&lt;/strong&gt; (vector database): supports HNSW alongside other structures.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Milvus&lt;/strong&gt; (open-source vector database): HNSW available as an index option.&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Verdict
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Reach for brute-force search when&lt;/strong&gt; you have &amp;lt;100k vectors and latency is not a constraint (offline analytics, one-time batch jobs).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Reach for HNSW when&lt;/strong&gt; you have millions of vectors, need sub-10ms latency, and can tolerate 1–5% recall loss (RAG, recommendation systems, real-time search).&lt;/p&gt;

&lt;p&gt;Watch the 90-second reel on YouTube to see this in motion: the walk down the layers, the greedy hops, the latency ticking from 1,240 ms down to 2 ms.&lt;/p&gt;

</description>
      <category>hnswvectorsearch</category>
      <category>programming</category>
      <category>softwareengineering</category>
    </item>
    <item>
      <title>RAG vs Fine-tuning</title>
      <dc:creator>Vahid Aghajani</dc:creator>
      <pubDate>Wed, 15 Jul 2026 07:35:10 +0000</pubDate>
      <link>https://dev.to/vahid_aghajani_60ce9dbec9/rag-vs-fine-tuning-2m64</link>
      <guid>https://dev.to/vahid_aghajani_60ce9dbec9/rag-vs-fine-tuning-2m64</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;📺 Prefer to watch? &lt;a href="https://youtu.be/71z_IxAmvYI" rel="noopener noreferrer"&gt;90-second YouTube Short&lt;/a&gt; · 💬 &lt;a href="https://t.me/SoftwareEngineerBlog" rel="noopener noreferrer"&gt;Telegram&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://software-engineer-blog.com/content/rag-vs-fine-tuning?id=117" rel="noopener noreferrer"&gt;software-engineer-blog.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The single most expensive misconception in applied AI right now is that fine-tuning teaches a model your documents. It doesn't — and entire GPU budgets get torched on this one mistake. The real split is clean, but almost nobody frames it this way: &lt;strong&gt;knowledge versus behaviour&lt;/strong&gt;.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Mental model&lt;/strong&gt;: RAG is a library you hand the model at query time; fine-tuning is teaching the model a writing style.&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  The confusion
&lt;/h2&gt;

&lt;p&gt;You have a set of internal documents. You want the model to answer questions about them. Two paths show up:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Fine-tune the model on those documents.&lt;/li&gt;
&lt;li&gt;Use RAG — retrieval augmented generation.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Most teams pick option 1, expecting the model to "know" the docs. Six weeks later, on GPU credits they can't get back, they realize the model still hallucinates. Then they find out about RAG. Then they argue about which one to use.&lt;/p&gt;

&lt;p&gt;The argument ends when you stop conflating knowledge with behaviour.&lt;/p&gt;




&lt;h2&gt;
  
  
  RAG: Knowledge at query time
&lt;/h2&gt;

&lt;p&gt;RAG doesn't change the model at all. It changes the prompt.&lt;/p&gt;

&lt;p&gt;Here's the flow:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Your documents live in a search index (typically a vector database: Pinecone, Weaviate, Milvus, or a simpler inverted index).&lt;/li&gt;
&lt;li&gt;A user asks a question.&lt;/li&gt;
&lt;li&gt;You retrieve the K most relevant chunks from that index.&lt;/li&gt;
&lt;li&gt;You paste those chunks into the prompt, above or below the user's question.&lt;/li&gt;
&lt;li&gt;The model reads them and answers.&lt;/li&gt;
&lt;li&gt;The weights never change.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Because the weights never change:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Sources are citable.&lt;/strong&gt; The model can say "according to page 47 of your policy manual."&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Facts stay current.&lt;/strong&gt; When your pricing changes, you reindex the new document. You don't retrain.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Hallucination is local.&lt;/strong&gt; If retrieval fails (wrong chunk fetched), you get a confident wrong answer from that chunk. The failure is visible — you can debug the retrieval pipeline.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The cost of RAG is the pipeline itself. You now own:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A chunking strategy (how do you split documents so the model can read them?)&lt;/li&gt;
&lt;li&gt;Embeddings (how do you represent each chunk as a vector?)&lt;/li&gt;
&lt;li&gt;A vector store (where do chunks live, and how do you search them?)&lt;/li&gt;
&lt;li&gt;Re-ranking or filtering (do the top K results actually matter?)&lt;/li&gt;
&lt;li&gt;Latency and token overhead (every prompt now includes retrieved chunks).&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;The weak link is retrieval.&lt;/strong&gt; Fetch the wrong chunk and the model answers confidently from it. This is operationally messier than it sounds: your monitoring has to watch for semantic drift in retrieval quality, not just model accuracy. A change to your embedding model can silently degrade recall.&lt;/p&gt;




&lt;h2&gt;
  
  
  Fine-tuning: Behaviour, not knowledge
&lt;/h2&gt;

&lt;p&gt;Fine-tuning changes the model's weights. You feed it thousands of input-output pairs, and the model adjusts its parameters to predict those outputs given those inputs.&lt;/p&gt;

&lt;p&gt;Fine-tuning teaches &lt;strong&gt;style&lt;/strong&gt;. Examples:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;"Always output valid JSON, never markdown."&lt;/li&gt;
&lt;li&gt;"Use our internal terminology: 'customer mandate' not 'contract'."&lt;/li&gt;
&lt;li&gt;"Never refuse; reframe instead."&lt;/li&gt;
&lt;li&gt;"Your tone is formal, clinical, concise."&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;These patterns — the structural regularities in your training data — get absorbed into the weights. The model learns to emit outputs that match the shape of your examples.&lt;/p&gt;

&lt;p&gt;Fine-tuning does &lt;strong&gt;not&lt;/strong&gt; teach new facts. This is the misconception that matters.&lt;/p&gt;

&lt;p&gt;Why? Because facts are not patterns. A fact is a specific piece of information: "Our API rate limit is 1000 requests per minute." When you fine-tune on documents containing that fact, the model doesn't store the fact. It learns correlations: tokens near "rate limit" tend to be followed by numbers in a certain range. Those correlations smear across the weight matrix. The model still hallucinates. It still gets the limit wrong half the time. And when you change the limit to 2000 requests per minute, there is no weight to edit — you have to retrain.&lt;/p&gt;

&lt;p&gt;Because weights are opaque, the model can't cite the source. It can't distinguish between what it learned during pre-training and what it learned during fine-tuning. Everything is probability.&lt;/p&gt;




&lt;h2&gt;
  
  
  Side-by-side: when each breaks down
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Dimension&lt;/th&gt;
&lt;th&gt;RAG&lt;/th&gt;
&lt;th&gt;Fine-tuning&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;What you're teaching&lt;/td&gt;
&lt;td&gt;New knowledge (facts, data)&lt;/td&gt;
&lt;td&gt;Consistent behavior (style, format, tone)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Does the model's weights change?&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Can the model cite sources?&lt;/td&gt;
&lt;td&gt;Yes (if retrieval includes source metadata)&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;How do you update when facts change?&lt;/td&gt;
&lt;td&gt;Reindex (days, sometimes hours)&lt;/td&gt;
&lt;td&gt;Retrain (weeks, GPU-intensive)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Hallucination risk if retrieval fails&lt;/td&gt;
&lt;td&gt;High (wrong chunk, confident wrong answer)&lt;/td&gt;
&lt;td&gt;High (weights encode fuzzy patterns, not facts)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cost per inference&lt;/td&gt;
&lt;td&gt;Higher (chunk tokens in prompt)&lt;/td&gt;
&lt;td&gt;Lower (no retrieval overhead)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Operational complexity&lt;/td&gt;
&lt;td&gt;Retrieval pipeline, embedding drift, chunk quality&lt;/td&gt;
&lt;td&gt;Training infrastructure, data labeling, version control&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;




&lt;h2&gt;
  
  
  The LLM inference lens
&lt;/h2&gt;

&lt;p&gt;If you're running a serving system, RAG and fine-tuning hit your latency budget differently.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;RAG&lt;/strong&gt; adds latency to &lt;strong&gt;time to first token (TTFT)&lt;/strong&gt;. The retrieval call (embedding the query, vector search, maybe re-ranking) happens before you send the prompt to the model. On a 100ms embedding latency + vector search, you're looking at 150–300ms added to TTFT before the model sees a token. Then chunks in the prompt increase the &lt;strong&gt;time per output token (TPOT)&lt;/strong&gt; because the KV cache is larger.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fine-tuning&lt;/strong&gt; shifts latency to training time (offline). Inference is faster — shorter prompts, no retrieval. But you pay for retraining whenever behavior needs to change.&lt;/p&gt;

&lt;p&gt;If you need both low latency and up-to-date facts, RAG is the only option. If you can tolerate retraining cycles, fine-tuning for behavior + a smaller RAG pipeline (for critical facts only) can reduce TTFT.&lt;/p&gt;




&lt;h2&gt;
  
  
  The verdict
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Reach for RAG when&lt;/strong&gt;: Your knowledge changes (documents, prices, policies, product specs). You need sources. You want to debug failures. You can afford the retrieval pipeline.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Reach for fine-tuning when&lt;/strong&gt;: Your model's &lt;em&gt;behaviour&lt;/em&gt; must stay consistent (output format, tone, terminology, refusal strategy). You're not adding facts; you're teaching a style.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;If you need both&lt;/strong&gt;: Fine-tune the behaviour first. Then wrap the fine-tuned model in a RAG pipeline that retrieves facts. Fine-tuning should &lt;em&gt;not&lt;/em&gt; carry the burden of knowledge management — it will fail at that job, and you'll waste time and GPU budget figuring out why.&lt;/p&gt;




&lt;p&gt;Watch the 90-second reel for the quick framing.&lt;/p&gt;

</description>
      <category>ragfinetuning</category>
      <category>programming</category>
      <category>softwareengineering</category>
    </item>
    <item>
      <title>bcrypt vs SHA-256: Why a Password Hash Should Be Slow on Purpose</title>
      <dc:creator>Vahid Aghajani</dc:creator>
      <pubDate>Mon, 13 Jul 2026 14:04:40 +0000</pubDate>
      <link>https://dev.to/vahid_aghajani_60ce9dbec9/bcrypt-vs-sha-256-why-a-password-hash-should-be-slow-on-purpose-1a49</link>
      <guid>https://dev.to/vahid_aghajani_60ce9dbec9/bcrypt-vs-sha-256-why-a-password-hash-should-be-slow-on-purpose-1a49</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;📺 Prefer to watch? &lt;a href="https://youtu.be/u7OUnPBo4pk" rel="noopener noreferrer"&gt;90-second YouTube Short&lt;/a&gt; · 💬 &lt;a href="https://t.me/SoftwareEngineerBlog" rel="noopener noreferrer"&gt;Telegram&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://software-engineer-blog.com/content/bcrypt-vs-sha-256-why-a-password-hash-should-be-slow-on-purpose?id=115" rel="noopener noreferrer"&gt;software-engineer-blog.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;"We hash our passwords" is the sentence that ends most security reviews. It should &lt;em&gt;start&lt;/em&gt; them — because the next question is the one that actually matters: &lt;strong&gt;with what?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Both bcrypt and SHA-256 are one-way hashes. Both turn &lt;code&gt;hunter2&lt;/code&gt; into a fixed-length blob you can't read backwards. And yet, when the database leaks, &lt;strong&gt;only one of them holds.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The difference is speed. And it's the opposite of what your instincts say.&lt;/p&gt;




&lt;h2&gt;
  
  
  SHA-256: a brilliant hash, doing the wrong job
&lt;/h2&gt;

&lt;p&gt;Let's be clear about something up front, because the internet gets this wrong constantly: &lt;strong&gt;SHA-256 is not broken.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;It's one-way. It's collision-resistant. It's the hash under git commits, TLS certificates, Bitcoin, and every checksum you've ever verified. It is a genuinely excellent cryptographic primitive, and it was designed with one dominant goal: &lt;strong&gt;be fast.&lt;/strong&gt; Hash a gigabyte of file in a blink. Hash a million records without noticing.&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="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;hashlib&lt;/span&gt;

&lt;span class="n"&gt;h&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;hashlib&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sha256&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;b&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;hunter2&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;hexdigest&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="c1"&gt;# f52fbd32b2b3b86ff88ef6c490628285f482af15ddcb29541f94bcf526a3f6c7
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Same input, same output. Every time. Which is exactly the property you want for a checksum — and exactly the property that kills you here.&lt;/p&gt;

&lt;h3&gt;
  
  
  Attackers don't reverse your hash
&lt;/h3&gt;

&lt;p&gt;This is the mental model flip. Nobody is sitting there trying to invert SHA-256. That's hard, and they don't need to.&lt;/p&gt;

&lt;p&gt;They &lt;strong&gt;guess&lt;/strong&gt;. They take a wordlist — the top 10 million leaked passwords, dictionary words, &lt;code&gt;Summer2024!&lt;/code&gt; and its ten thousand cousins — hash each guess, and compare against your dump. It's a race, and the only thing that limits them is &lt;strong&gt;how many guesses per second the hardware can do.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;With SHA-256, that number is obscene. A single consumer GPU chews through roughly &lt;strong&gt;10,000,000,000 SHA-256 hashes per second.&lt;/strong&gt; Ten billion. Per second. On one card.&lt;/p&gt;

&lt;p&gt;Your leaked user table doesn't survive the weekend. It survives &lt;strong&gt;hours&lt;/strong&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  And without a salt, it's worse
&lt;/h3&gt;

&lt;p&gt;SHA-256 has no salt built in. If two of your users pick the same password, they get &lt;strong&gt;the same hash&lt;/strong&gt;, sitting right next to each other in the dump:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;alice@corp.com   f52fbd32b2b3b86f...
bob@corp.com     f52fbd32b2b3b86f...   ← same hash = same password
carol@corp.com   f52fbd32b2b3b86f...   ← this one's popular, crack it once
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Crack it once, own three accounts. Worse still, an attacker doesn't even have to do the work — they can precompute a giant table of &lt;code&gt;hash → password&lt;/code&gt; once (a &lt;strong&gt;rainbow table&lt;/strong&gt;) and then just &lt;em&gt;look yours up&lt;/em&gt;. The cracking cost drops to a database join.&lt;/p&gt;




&lt;h2&gt;
  
  
  bcrypt: a hash that inverts every property on purpose
&lt;/h2&gt;

&lt;p&gt;bcrypt isn't a general-purpose hash. It's a &lt;strong&gt;password hash&lt;/strong&gt;, and it was designed by someone who had already thought through everything above. It takes SHA-256's virtues and deliberately throws them away.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. It's slow. That's the entire point.&lt;/strong&gt; bcrypt is built around a deliberately expensive key-setup step. It cannot be made fast, and it resists the GPU parallelism that makes SHA-256 cracking so cheap.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Every hash carries its own random salt.&lt;/strong&gt; You don't manage it, you don't store it in a second column — bcrypt generates it per password and writes it &lt;em&gt;inside the hash string&lt;/em&gt;. Same password, two users, two completely different hashes. Rainbow tables die instantly.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. The cost is a dial you control.&lt;/strong&gt; That's the &lt;code&gt;12&lt;/code&gt; below — the &lt;strong&gt;work factor&lt;/strong&gt;. Cost 12 means 2¹² = 4,096 rounds of key setup. Each +1 &lt;strong&gt;doubles&lt;/strong&gt; the work, forever. Hardware gets faster? Bump the number.&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="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;bcrypt&lt;/span&gt;

&lt;span class="c1"&gt;# Hashing — the salt is generated for you and baked into the output
&lt;/span&gt;&lt;span class="n"&gt;hashed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;bcrypt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;hashpw&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;b&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;hunter2&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;bcrypt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;gensalt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;rounds&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;12&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;span class="c1"&gt;# $2b$12$eImiTXuWVxfM37uY4JANjQuwoNw2NvJ2ZbcFTz4dGrvQoOHnBrOZK
#  │   │  └─ the salt, right there in the string
#  │   └──── cost factor: 12
#  └──────── algorithm: bcrypt
&lt;/span&gt;
&lt;span class="c1"&gt;# Verifying — no salt column to look up, it's already in the hash
&lt;/span&gt;&lt;span class="n"&gt;bcrypt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;checkpw&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;b&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;hunter2&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;hashed&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;   &lt;span class="c1"&gt;# True
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Read that output format for a second, because it's the elegant part: &lt;strong&gt;the algorithm, the cost, and the salt all travel with the hash.&lt;/strong&gt; You can raise the cost factor next year and old hashes still verify — they carry their own instructions.&lt;/p&gt;

&lt;h3&gt;
  
  
  What that does to the attacker
&lt;/h3&gt;

&lt;p&gt;At cost 12, one hash takes roughly &lt;strong&gt;250 milliseconds&lt;/strong&gt;. For a user logging in, that's imperceptible. For someone with your entire database and a rack of GPUs, it's a wall:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;SHA-256&lt;/th&gt;
&lt;th&gt;bcrypt (cost 12)&lt;/th&gt;
&lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
&lt;td&gt;Guesses/sec (1 GPU)&lt;/td&gt;
&lt;td&gt;~10,000,000,000&lt;/td&gt;
&lt;td&gt;~5,000&lt;/td&gt;
&lt;/tr&gt;
    &lt;tr&gt;
&lt;td&gt;Salt&lt;/td&gt;
&lt;td&gt;None by default&lt;/td&gt;
&lt;td&gt;Per-password, automatic&lt;/td&gt;
&lt;/tr&gt;
    &lt;tr&gt;
&lt;td&gt;Identical passwords&lt;/td&gt;
&lt;td&gt;Identical hashes&lt;/td&gt;
&lt;td&gt;Different hashes&lt;/td&gt;
&lt;/tr&gt;
    &lt;tr&gt;
&lt;td&gt;Rainbow tables&lt;/td&gt;
&lt;td&gt;Effective&lt;/td&gt;
&lt;td&gt;Useless&lt;/td&gt;
&lt;/tr&gt;
    &lt;tr&gt;
&lt;td&gt;Tunable over time&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Yes (cost factor)&lt;/td&gt;
&lt;/tr&gt;
    &lt;tr&gt;
&lt;td&gt;Time to crack a leak&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Hours&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Centuries&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
    &lt;tr&gt;
&lt;td&gt;Right job&lt;/td&gt;
&lt;td&gt;Integrity, signatures, HMAC&lt;/td&gt;
&lt;td&gt;Passwords&lt;/td&gt;
&lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Same leak. Same hardware. Same passwords. &lt;strong&gt;Hours versus centuries&lt;/strong&gt; — and the only thing that changed is that you picked a hash that refuses to hurry.&lt;/p&gt;




&lt;h2&gt;
  
  
  What bcrypt actually costs you (it isn't free)
&lt;/h2&gt;

&lt;p&gt;Anyone who sells you bcrypt as a pure win is skipping the invoice. There are three real costs, and all three have bitten production systems.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. You pay the 250 ms on every single login.&lt;/strong&gt; That's CPU, on your servers, per authentication. It's fine at a trickle. But a Monday-morning login storm — or a credential-stuffing bot hammering &lt;code&gt;/login&lt;/code&gt; — turns a traffic spike into a &lt;strong&gt;CPU spike&lt;/strong&gt;, and your own auth endpoint becomes the DoS. The fix isn't to lower the cost until it stops hurting; it's to &lt;strong&gt;rate-limit login attempts&lt;/strong&gt; and size the box for the peak.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. The work factor is a knob you have to keep turning.&lt;/strong&gt; A cost that was painful for attackers in 2015 is comfortable for them now. The number isn't set-and-forget — it's a &lt;strong&gt;budget&lt;/strong&gt;: pick the highest cost that keeps you around ~250 ms on &lt;em&gt;your&lt;/em&gt; hardware, and re-measure every couple of years. (Because old hashes carry their own cost factor, you can upgrade lazily: on a successful login, if the stored cost is below your current target, re-hash and store.)&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. bcrypt silently truncates past 72 bytes.&lt;/strong&gt; This one is a genuine footgun. Feed bcrypt a long passphrase and everything beyond byte 72 is &lt;strong&gt;ignored&lt;/strong&gt; — no error, no warning. Two different 80-character passphrases sharing a 72-byte prefix will happily verify against each other. If you encourage long passphrases (you should), you need to know this.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The escape hatch:&lt;/strong&gt; if any of that makes you nervous, reach for &lt;strong&gt;argon2id&lt;/strong&gt; instead. It's the modern recommendation — no truncation, and it's tunable on memory as well as time, which makes GPU and ASIC attacks even more expensive. bcrypt is fine, battle-tested, and everywhere; argon2id is what you'd pick starting fresh today. &lt;strong&gt;Either one is a correct answer. SHA-256 is not.&lt;/strong&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  The AI-era version of the same mistake
&lt;/h2&gt;

&lt;p&gt;Here's where this gets freshly relevant, because the same decision shows up in every LLM app being built right now — and the &lt;em&gt;right answer flips&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;Your AI product ships an API. Every inference request arrives with an &lt;strong&gt;API key&lt;/strong&gt; (&lt;code&gt;sk-live-9f3a...&lt;/code&gt;), and you have to check it against the database on every call. So: bcrypt, right? Slow is safe, we just established that.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;No.&lt;/strong&gt; Do that and you've bolted 250 ms of CPU onto every single request to your model endpoint — on an endpoint whose whole selling point may be a sub-second &lt;strong&gt;time-to-first-token&lt;/strong&gt;. You've made your auth layer slower than your LLM, and you've handed anyone with a load generator a trivial way to melt your gateway.&lt;/p&gt;

&lt;p&gt;The reason it flips is the thing bcrypt was compensating for in the first place: &lt;strong&gt;entropy.&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A &lt;strong&gt;password&lt;/strong&gt; is chosen by a human. It's low-entropy, it's guessable, it's in a wordlist. bcrypt's slowness exists to make &lt;em&gt;guessing&lt;/em&gt; uneconomical.&lt;/li&gt;
&lt;li&gt;An &lt;strong&gt;API key&lt;/strong&gt; is generated by &lt;em&gt;you&lt;/em&gt;, from a CSPRNG, with 256 bits of randomness. It is not in any wordlist. There is nothing to guess. Ten billion guesses per second against a 256-bit random key is still, functionally, forever.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So for high-entropy secrets you've generated yourself — API keys, session tokens, password-reset tokens, webhook signatures — &lt;strong&gt;the fast hash is the correct hash.&lt;/strong&gt; Store &lt;code&gt;SHA-256(key)&lt;/code&gt;, look it up by that digest on every request, and compare in constant time. Fast lookup, nothing sensitive at rest, no CPU tax on your hot path.&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="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;hashlib&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;secrets&lt;/span&gt;

&lt;span class="c1"&gt;# Issue: 256 bits from a CSPRNG. Show it to the user exactly once.
&lt;/span&gt;&lt;span class="n"&gt;raw_key&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;sk-live-&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;secrets&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;token_urlsafe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;32&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="c1"&gt;# Store: only the fast digest. A leaked table of these is worthless —
# there's no wordlist for 256 random bits.
&lt;/span&gt;&lt;span class="n"&gt;key_digest&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;hashlib&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sha256&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;raw_key&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;encode&lt;/span&gt;&lt;span class="p"&gt;()).&lt;/span&gt;&lt;span class="nf"&gt;hexdigest&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

&lt;span class="c1"&gt;# Verify, on every inference request: one hash, one indexed lookup.
# Constant-time compare to avoid leaking the digest byte-by-byte.
&lt;/span&gt;&lt;span class="n"&gt;secrets&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;compare_digest&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;key_digest&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;row&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;key_digest&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Same two algorithms. Opposite verdict. Because the question was never "which hash is stronger" — it was always &lt;strong&gt;"what is the attacker's cheapest path in?"&lt;/strong&gt; For a human-chosen password, that path is guessing, so you make guessing slow. For a 256-bit random token, that path doesn't exist, so you optimize for the thing that does matter: throughput.&lt;/p&gt;

&lt;p&gt;That's the real skill. Not memorizing "bcrypt good, SHA-256 bad" — but knowing &lt;em&gt;which threat you're actually paying to defend against.&lt;/em&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  The verdict
&lt;/h2&gt;

&lt;p&gt;The whole lesson compresses into one line:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;The property you want in a password hash is the exact opposite of the one you want everywhere else.&lt;/strong&gt; Slow, not fast.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Storing user passwords?&lt;/strong&gt; → &lt;strong&gt;bcrypt&lt;/strong&gt; (cost ~12, budget the 250 ms, rate-limit your login endpoint, mind the 72-byte limit) — or &lt;strong&gt;argon2id&lt;/strong&gt; if you're starting fresh.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Integrity, checksums, git objects, digital signatures, HMAC, hashing high-entropy API keys?&lt;/strong&gt; → &lt;strong&gt;SHA-256&lt;/strong&gt;, and don't feel bad about it for a second. That's the job it was built for, and it's superb at it.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;SHA-256 isn't broken. It never was. It's just the wrong tool for this one job — and "we hashed it" was never the same sentence as "it's safe."&lt;/p&gt;

&lt;p&gt;So: which one is in your users table?&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Want the 100-second visual version? &lt;a href="https://youtube.com/shorts/u7OUnPBo4pk" rel="noopener noreferrer"&gt;Watch the reel.&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;

</description>
      <category>security</category>
      <category>programming</category>
      <category>softwareengineering</category>
      <category>webdev</category>
    </item>
  </channel>
</rss>
