<?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: Nemwel Boniface</title>
    <description>The latest articles on DEV Community by Nemwel Boniface (@nemwelboniface).</description>
    <link>https://dev.to/nemwelboniface</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%2F879351%2F80229b4f-1365-4363-a05b-08ca3d379f18.jpeg</url>
      <title>DEV Community: Nemwel Boniface</title>
      <link>https://dev.to/nemwelboniface</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/nemwelboniface"/>
    <language>en</language>
    <item>
      <title>Best Open-Source OCR and Document Parsing Models for Financial Tables (2026)</title>
      <dc:creator>Nemwel Boniface</dc:creator>
      <pubDate>Sat, 17 Oct 2026 14:33:05 +0000</pubDate>
      <link>https://dev.to/nemwelboniface/best-open-source-ocr-and-document-parsing-models-for-financial-tables-2026-156g</link>
      <guid>https://dev.to/nemwelboniface/best-open-source-ocr-and-document-parsing-models-for-financial-tables-2026-156g</guid>
      <description>&lt;p&gt;Hello friends! I'm back with a follow-up to my &lt;a href="https://dev.to/nemwelboniface/best-ai-inference-platforms-for-financial-document-processing-in-2026-why-superlinked-sie-tops-my-4kaa"&gt;last article&lt;/a&gt; on choosing an inference platform for financial documents.&lt;/p&gt;

&lt;p&gt;That article was about the &lt;em&gt;where&lt;/em&gt;: where your document models run, what they cost, and whether client data ever leaves your environment. This one is about the &lt;em&gt;what&lt;/em&gt;: the open-source models that actually sit inside that platform and read the tables. And trust me, when it comes to financial filings, reading tables is a very different job from reading text.&lt;/p&gt;

&lt;p&gt;You might think OCR is a solved problem by now. For a paragraph of text? Mostly, yes. For a balance sheet with merged headers, three years of comparatives and negative numbers wrapped in brackets? Not even close. Let me explain.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fl3ls8njgbsgeffw3jgg9.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fl3ls8njgbsgeffw3jgg9.gif" alt="GIF: a cat staring at a spreadsheet in total confusion" width="480" height="270"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Disclosure:&lt;/strong&gt; This article was written in partnership with &lt;a href="https://superlinked.com/" rel="noopener noreferrer"&gt;Superlinked&lt;/a&gt;. It compares open-weight OCR and document-parsing options for financial tables and discusses how Superlinked Inference Engine can help teams deploy them.&lt;/p&gt;

&lt;h2&gt;
  
  
  Introduction: The Bracket That Lied
&lt;/h2&gt;

&lt;p&gt;Picture this. A balance sheet comes back from your OCR pipeline looking spotless. Every cell is filled, every column is aligned, and the person reviewing it gives it a quick glance and moves on.&lt;/p&gt;

&lt;p&gt;Except one line used to say &lt;code&gt;(412)&lt;/code&gt;, and now it says &lt;code&gt;412&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;In accounting, those brackets mean &lt;em&gt;negative&lt;/em&gt;. The model saw them, decided they were punctuation, and threw them away. While it was at it, it also read a little superscript footnote marker as one more digit. So a loss became a profit, a number grew a tail, and nothing in the pipeline raised a hand. No exception, no low-confidence warning, no red squiggly line. Just a tidy table that is confidently wrong.&lt;/p&gt;

&lt;p&gt;That's what makes financial tables so unforgiving. A single number often depends on things that aren't in the number at all:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The header above it&lt;/strong&gt;, which might span two or three columns at once.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The column it sits in&lt;/strong&gt;, where shifting one position means the figure now belongs to the wrong year.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The brackets around it&lt;/strong&gt;, which decide whether it's money in or money out.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A small line of text above the whole table&lt;/strong&gt;, like &lt;em&gt;"in millions, except per-share data"&lt;/em&gt;, which quietly multiplies every number below it and appears in none of the cells.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A model can be brilliant at reading receipts and invoices and still fall over on every one of those. So in this article, I'm ranking open-source OCR models specifically on how much &lt;strong&gt;evidence&lt;/strong&gt; they have for handling &lt;em&gt;tables&lt;/em&gt;, and I'll show you how to check them on your own documents so you don't have to take my word (or anyone's leaderboard) for it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Wait. Do You Even Need OCR?
&lt;/h2&gt;

&lt;p&gt;Look, hear me out on this one before we get to the models, because it's the cheapest win in this whole article.&lt;/p&gt;

&lt;p&gt;If you read my last piece, you already know the punchline: most SEC filings don't need OCR at all. 10-Ks, 10-Qs and EX-99.1 earnings releases on EDGAR are &lt;strong&gt;HTML&lt;/strong&gt;, and the main filings carry &lt;strong&gt;Inline XBRL&lt;/strong&gt; since the 2019 to 2021 phase-in. The tagged numbers come straight out of the SEC's XBRL APIs, and the untagged tables come out of a normal HTML parser. Sending those to an OCR model is paying someone to read your handwriting when you already typed the letter.&lt;/p&gt;

&lt;p&gt;OCR earns its keep on the documents that only exist as pictures of text: &lt;strong&gt;foreign annual reports never filed with the SEC, data-room packs, client uploads and scanned exhibits from before everything went digital.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;And here's a step most guides skip: even inside that PDF pile, not every page needs the heavy machinery. Plenty of PDFs are born digital and already carry a text layer. A few lines of Python can sort your pages into buckets &lt;em&gt;before&lt;/em&gt; any model sees them:&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;re&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;pypdf&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;PdfReader&lt;/span&gt;

&lt;span class="n"&gt;NUMBER&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;re&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;compile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;r&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;\(?\d[\d,]*\.?\d*\)?&lt;/span&gt;&lt;span class="sh"&gt;"&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;triage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;min_chars&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;50&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;min_numbers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;6&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Sort PDF pages into buckets before any model sees them.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="n"&gt;buckets&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;needs_ocr&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[],&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;table_candidates&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[],&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;plain_text&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[]}&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;page_no&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;page&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;enumerate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;PdfReader&lt;/span&gt;&lt;span class="p"&gt;(&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;pages&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;start&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;text&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;extract_text&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="sh"&gt;""&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;strip&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;min_chars&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;buckets&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;needs_ocr&lt;/span&gt;&lt;span class="sh"&gt;"&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;page_no&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;          &lt;span class="c1"&gt;# no usable text layer: likely a scan
&lt;/span&gt;        &lt;span class="k"&gt;elif&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;NUMBER&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;findall&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="n"&gt;min_numbers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;buckets&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;table_candidates&lt;/span&gt;&lt;span class="sh"&gt;"&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;page_no&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;   &lt;span class="c1"&gt;# lots of figures: send to a table model
&lt;/span&gt;        &lt;span class="k"&gt;else&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;buckets&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;plain_text&lt;/span&gt;&lt;span class="sh"&gt;"&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;page_no&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;         &lt;span class="c1"&gt;# prose: the text layer is enough
&lt;/span&gt;    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;buckets&lt;/span&gt;

&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;triage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;annual_report.pdf&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;span class="c1"&gt;# e.g. {'needs_ocr': [2], 'table_candidates': [1], 'plain_text': [3]}
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It's deliberately crude (tune the thresholds on your own documents), but it means the chairman's letter on page 3 never goes anywhere near a GPU, and your table models only see the pages that actually have tables. Keep this triage in mind, because it's going to come back when we talk about running these models in production.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two Terms You'll See a Lot (and Why They Matter)
&lt;/h2&gt;

&lt;p&gt;Before the ranking, a quick translation of the jargon you'll find on every model card, because the numbers mean very little until you know what they're measuring.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;FinTabNet&lt;/strong&gt; is a benchmark built from tables in real company annual reports. In other words, it's the closest thing we have to a public exam made of the exact kind of tables this article is about. A model with a published FinTabNet score has been tested on balance sheets, not just on academic papers and invoices.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;TEDS&lt;/strong&gt; (Tree-Edit-Distance-based Similarity) is how most table benchmarks grade a model. It turns both the model's table and the correct table into a tree, the same way HTML nests rows inside tables and cells inside rows, and measures how many edits it takes to turn one into the other. A perfect score is 100, or 1.0, depending on who's reporting it. So when you see one model at &lt;strong&gt;96.8&lt;/strong&gt; and another at &lt;strong&gt;0.97&lt;/strong&gt;, they're on the same scale, just written differently.&lt;/p&gt;

&lt;p&gt;Here's the catch that I think matters most: &lt;strong&gt;TEDS grades the whole table.&lt;/strong&gt; A model that gets the structure of a 200-cell balance sheet right but drops the brackets from one cell will still score beautifully. A great TEDS score tells you a model understands table &lt;em&gt;structure&lt;/em&gt;. It doesn't promise that every sign survived. We'll fix that later with a simple cell-level check.&lt;/p&gt;

&lt;p&gt;One more thing: you'll see a lot of models quoting &lt;strong&gt;OmniDocBench&lt;/strong&gt;, a broader document-parsing benchmark. It comes in several versions (the original 2025 release, v1.5 and v1.6), and they're different tests. Comparing a v1.5 score to a v1.6 score is like comparing two people's times on different running routes. Both times are real; they just don't tell you who's faster.&lt;/p&gt;

&lt;h2&gt;
  
  
  How I Picked and Ordered These Models
&lt;/h2&gt;

&lt;p&gt;My rules were simple, and I'm sharing them so you can argue with the ranking properly in the comments:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Open weights with a licence that allows commercial use, checked on the exact checkpoint.&lt;/strong&gt; If you can't ship it in a product, it's not on the list.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Finance-table evidence first.&lt;/strong&gt; A published FinTabNet result beats everything else. Then other table-specific evidence, then whether the model preserves structure and reading order, then licence clarity, then size.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Benchmarks inform the order; they don't decide it.&lt;/strong&gt; Almost every number on these model cards comes from the people who built the model.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Deployment options don't affect rank.&lt;/strong&gt; I note them for every model because you'll care, but being easier to host doesn't buy a higher spot.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Alright, let's get into it.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Docling: The One You Can Explain to an Auditor
&lt;/h2&gt;

&lt;p&gt;If your tables are going anywhere near an audit, a regulator or a picky finance director, start here.&lt;/p&gt;

&lt;p&gt;Docling isn't really a single model. It's a &lt;strong&gt;pipeline&lt;/strong&gt; of specialists: one model (Layout Heron) works out where everything sits on the page, a dedicated table model (&lt;strong&gt;TableFormer&lt;/strong&gt;) rebuilds the rows and columns, and an OCR engine reads the actual characters (RapidOCR PP-OCRv4 in SIE's artifact bundle). Think of it like a small team where everyone has one job, instead of one person trying to do everything at once. When something goes wrong, you can tell &lt;em&gt;which&lt;/em&gt; step got it wrong, and that's a luxury you rarely get with end-to-end models.&lt;/p&gt;

&lt;p&gt;The evidence is the strongest on this list. The TableFormer paper (arXiv 2203.01017, 2022) reports a FinTabNet TEDS of &lt;strong&gt;96.8&lt;/strong&gt; overall, &lt;strong&gt;97.5&lt;/strong&gt; on simple tables and &lt;strong&gt;96.0&lt;/strong&gt; on complex ones. I'll be honest about one thing: that number comes from the paper, not the current weights, so read it as proof that the &lt;em&gt;approach&lt;/em&gt; works on financial tables rather than a guarantee for today's exact checkpoint.&lt;/p&gt;

&lt;p&gt;What really earns it the top spot, though, is that Docling rebuilds &lt;strong&gt;tables as tables&lt;/strong&gt; and keeps the reading order intact, so a three-year comparative stays three columns instead of melting into one long list of numbers. As a library, it also records a &lt;strong&gt;page number and bounding box for every element&lt;/strong&gt;. When someone asks &lt;em&gt;"where did this figure come from?"&lt;/em&gt;, you can point at the exact spot on the exact page. In finance, that answer can matter more than a few extra benchmark points.&lt;/p&gt;

&lt;p&gt;It's also refreshingly easy to try:&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;docling.document_converter&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;DocumentConverter&lt;/span&gt;

&lt;span class="n"&gt;converter&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;DocumentConverter&lt;/span&gt;&lt;span class="p"&gt;()&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;converter&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;convert&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;foreign_issuer_annual_report.pdf&lt;/span&gt;&lt;span class="sh"&gt;"&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;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;export_to_markdown&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;i&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;table&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;enumerate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;tables&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;df&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;table&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;export_to_dataframe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;doc&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;document&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="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Table &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;df&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;shape&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="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; rows x &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;df&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;shape&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="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; columns&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Where it can stumble:&lt;/strong&gt; on a poor scan, TableFormer can only rebuild structure from whatever text the OCR engine managed to read. A perfect grid of misread digits is still wrong.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The fine print:&lt;/strong&gt; MIT code. TableFormer weights are CDLA-Permissive-2.0, and Layout Heron and RapidOCR are Apache-2.0, so commercial use is fine. Small enough to run on CPU, served on &lt;a href="https://superlinked.com/serverless" rel="noopener noreferrer"&gt;SIE Cloud&lt;/a&gt; as &lt;code&gt;docling&lt;/code&gt;, and backed by a project with &lt;strong&gt;68.2k GitHub stars&lt;/strong&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. granite-docling-258M: Tiny Model, Serious Receipts
&lt;/h2&gt;

&lt;p&gt;If you like the Docling approach but want something smaller and simpler, this is your model.&lt;/p&gt;

&lt;p&gt;IBM's &lt;a href="https://huggingface.co/ibm-granite/granite-docling-258M" rel="noopener noreferrer"&gt;granite-docling-258M&lt;/a&gt; does the whole conversion in one go, turning a page into &lt;strong&gt;DocTags&lt;/strong&gt;, a structured markup that keeps track of what's a heading, what's a paragraph and what's a table. It can also focus on a specific region of a page when you give it a bounding box.&lt;/p&gt;

&lt;p&gt;At just 258 million parameters, it has no business carrying the receipts it does. Its model card reports FinTabNet scores of &lt;strong&gt;0.97 for structure&lt;/strong&gt; and &lt;strong&gt;0.96 including content&lt;/strong&gt;, at 150 dpi, compared with 0.82 and 0.76 for its predecessor, SmolDocling. That makes it one of only two models on this list with published results on real financial tables, and the other one is number one.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why it's not first:&lt;/strong&gt; it's a single model, so there are fewer places to look when a table goes sideways, and that score comes from its own publisher. One benchmark from the people who built it is a great reason to test it. It's not a reason to skip testing. The good news is that at this size, testing is about as cheap as it gets.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The fine print:&lt;/strong&gt; Apache-2.0. Open weights that you serve yourself, on a modest GPU or even locally.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. MinerU2.5-Pro: For Notes That Refuse to Fit on One Page
&lt;/h2&gt;

&lt;p&gt;Every annual report has them: the notes to the accounts that start on page 112 and finally end on page 115, with the header row only printed once at the very top. If those are your nightmare, this is your model.&lt;/p&gt;

&lt;p&gt;But first, a warning, because this one catches people out. &lt;strong&gt;Read the licence on the exact checkpoint:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;opendatalab/MinerU2.5-Pro-2604-1.2B&lt;/code&gt; is &lt;strong&gt;Apache-2.0&lt;/strong&gt;, so commercial use is fine.&lt;/li&gt;
&lt;li&gt;The earlier &lt;code&gt;MinerU2.5-2509-1.2B&lt;/code&gt; is &lt;strong&gt;AGPL-3.0&lt;/strong&gt;, which is a very different conversation with your legal team.&lt;/li&gt;
&lt;li&gt;The MinerU toolkit code has its own custom licence, separate from both.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Same family name, three different situations. Pin the checkpoint, and you'll sleep fine.&lt;/p&gt;

&lt;p&gt;Once that's sorted, the toolkit handles &lt;strong&gt;tables that run across pages&lt;/strong&gt;, which is exactly where multi-year notes break. Remember the segment table from my last article that lost its header row at a page break? This is the first model I'd point at it.&lt;/p&gt;

&lt;p&gt;The publisher's numbers are ambitious: &lt;strong&gt;95.69 on OmniDocBench v1.6&lt;/strong&gt;, up from 92.98 for MinerU 2.5, a &lt;strong&gt;5.54-point jump in table TEDS&lt;/strong&gt;, and a &lt;strong&gt;1.39-point lead&lt;/strong&gt; over the second-placed model across five table benchmarks. It also claims to beat GLM-OCR, PaddleOCR-VL-1.5 and larger general models. Two things keep it at number three: those are vendor numbers, and v1.6 is a benchmark version the same publisher proposed.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The fine print:&lt;/strong&gt; Apache-2.0 for the 2604 checkpoint. 1.2B parameters, served on your own GPUs or through SIE's self-hosted catalog.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. PaddleOCR-VL-1.5: For Scans That Have Seen Things
&lt;/h2&gt;

&lt;p&gt;Older exhibits are rarely scanned nicely. Pages come in tilted, curved near the spine or photocopied one too many times. If that sounds like your archive, this is the model to try.&lt;/p&gt;

&lt;p&gt;PaddleOCR-VL-1.5 is a compact &lt;strong&gt;0.9B&lt;/strong&gt; model from PaddlePaddle that reads a whole page end to end, tables included. Its card specifically calls out &lt;strong&gt;tolerance for scans, skew and warped pages&lt;/strong&gt;, and like MinerU, it can &lt;strong&gt;merge tables across pages&lt;/strong&gt; and recognise headings that continue onto the next page. It even does seal recognition, which you'll almost never need for filings, but hey, it's there.&lt;/p&gt;

&lt;p&gt;Its publisher reports &lt;strong&gt;94.5 on OmniDocBench v1.5&lt;/strong&gt; and claims first place overall and on tables. You'll notice a pattern here: nearly every publisher claims first place somewhere. That's not a criticism; it's just why rule 3 exists. That v1.5 number can't be lined up against MinerU's v1.6 number as though they ran the same race.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why fourth:&lt;/strong&gt; solid table support, but its biggest strength is coping with ugly scans rather than finance-specific evidence.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The fine print:&lt;/strong&gt; Apache-2.0. 0.9B parameters, for your own hardware, and listed in SIE's self-hosted catalog.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. GLM-OCR: Neck and Neck With PaddleOCR
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://huggingface.co/zai-org/GLM-OCR" rel="noopener noreferrer"&gt;GLM-OCR&lt;/a&gt; from Z.ai (0.9B, according to Z.ai) reports &lt;strong&gt;94.62 on OmniDocBench v1.5&lt;/strong&gt;, first overall at its release. PaddleOCR-VL-1.5 reports &lt;strong&gt;94.5&lt;/strong&gt; on the same version and also claimed first at its release.&lt;/p&gt;

&lt;p&gt;A 0.12-point difference is well inside the noise of a single benchmark run. If anyone tells you one of these is clearly better than the other based on that gap, smile politely and run both on your own pages.&lt;/p&gt;

&lt;p&gt;It sits one spot lower for a practical reason: layout detection is handled by a &lt;strong&gt;separate model, PP-DocLayout-V3&lt;/strong&gt;. That isn't a flaw, but it's one more thing to deploy, version and keep in step. And if you read my Celery post-mortem, you know how I feel about two things that have to stay in sync by hand.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The fine print:&lt;/strong&gt; MIT weights, plus Apache-2.0 for the layout model, and the card asks you to comply with both. Runs on Linux with NVIDIA GPUs, directly or through SIE's self-hosted catalog. &lt;strong&gt;1.8M downloads&lt;/strong&gt; on its card, so you're in good company.&lt;/p&gt;

&lt;h2&gt;
  
  
  6. dots.ocr: Every Number Comes With a Home Address
&lt;/h2&gt;

&lt;p&gt;If your product needs to highlight exactly where on the page a number came from, this one is worth a look.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://huggingface.co/dots-studio/dots.ocr" rel="noopener noreferrer"&gt;dots.ocr&lt;/a&gt; does layout and reading in a single model (a 1.7B language base, roughly 3.0B parameters in total). Its standout feature is the &lt;strong&gt;output&lt;/strong&gt;: for every element on the page, it returns a &lt;strong&gt;bounding box, a category and the text&lt;/strong&gt;, and tables come back as &lt;strong&gt;HTML&lt;/strong&gt;. That's ready to plug into a review screen or an audit trail without writing a pile of glue code.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why sixth:&lt;/strong&gt; its benchmark claims are on the &lt;strong&gt;original 2025 OmniDocBench&lt;/strong&gt;, a different test from the v1.5 and v1.6 numbers above, so there was nothing comparable to rank it against. That says more about the benchmarks than the model.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The fine print:&lt;/strong&gt; MIT, on both the model card and the repository. The project has moved to &lt;code&gt;dots-studio/dots.ocr&lt;/code&gt;, and old links redirect. About 3B parameters on your own hardware.&lt;/p&gt;

&lt;h2&gt;
  
  
  7. LightOnOCR-2-1B: The Bouncer at the Door
&lt;/h2&gt;

&lt;p&gt;Putting LightOnOCR last on a &lt;em&gt;table&lt;/em&gt; list might look harsh. It isn't. It's just not a table model.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://huggingface.co/lightonai/LightOnOCR-2-1B" rel="noopener noreferrer"&gt;LightOnOCR-2-1B&lt;/a&gt; is a 1B &lt;strong&gt;page-to-text&lt;/strong&gt; model, and LightOn reports state-of-the-art results on OlmOCR-Bench while being about &lt;strong&gt;nine times smaller&lt;/strong&gt; than comparable models. What its card doesn't have is any table-specific evidence, and it doesn't return coordinates for text (its &lt;code&gt;-bbox&lt;/code&gt; variants only locate embedded images).&lt;/p&gt;

&lt;p&gt;So don't use it as your table reader. Use it as the &lt;strong&gt;bouncer at the door&lt;/strong&gt;. Remember the triage script from earlier? Pair it with LightOnOCR for a fast, cheap pass over a big archive of scanned pages, find the ones that actually contain tables, and only send those to Docling or MinerU. That's the same instinct as my PaceTracker article: don't do expensive work until something genuinely needs it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The fine print:&lt;/strong&gt; Apache-2.0. 1B parameters, on your own hardware or on &lt;a href="https://superlinked.com/serverless" rel="noopener noreferrer"&gt;SIE Cloud&lt;/a&gt; at &lt;strong&gt;$2 per 1,000 pages&lt;/strong&gt; (listed 2026-09-30).&lt;/p&gt;

&lt;h3&gt;
  
  
  Didn't Make the Cut: olmOCR-2
&lt;/h3&gt;

&lt;p&gt;olmOCR-2 was on my shortlist until I read its own benchmark, where &lt;strong&gt;Tables&lt;/strong&gt; scored &lt;strong&gt;48.3&lt;/strong&gt;, its weakest category. On a list about tables, the model ruled itself out.&lt;/p&gt;

&lt;h2&gt;
  
  
  So, Why Not Just Pay for a Hosted API?
&lt;/h2&gt;

&lt;p&gt;Fair question. Hosted services exist, they work, and some of them are very good:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Hosted option&lt;/th&gt;
&lt;th&gt;Listed price&lt;/th&gt;
&lt;th&gt;What you get&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Azure AI Document Intelligence (Layout)&lt;/td&gt;
&lt;td&gt;$10 / 1,000 pages (US East)&lt;/td&gt;
&lt;td&gt;Structured tables, nothing to choose&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Amazon Textract (Tables)&lt;/td&gt;
&lt;td&gt;$0.015 / page (US West, Oregon)&lt;/td&gt;
&lt;td&gt;Native to AWS, zero operations&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Mistral OCR 4.1&lt;/td&gt;
&lt;td&gt;$4 / 1,000 pages&lt;/td&gt;
&lt;td&gt;Strong table handling, per Mistral's own claim&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Google Document AI (Layout Parser)&lt;/td&gt;
&lt;td&gt;$10 / 1,000 pages&lt;/td&gt;
&lt;td&gt;Layout parsing inside GCP&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;em&gt;Prices as of 2026-09-30.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;When a hosted API is the better call:&lt;/strong&gt;&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;You're all-in on one cloud, your documents are allowed to go there, and you want zero operations.&lt;/li&gt;
&lt;li&gt;You don't have the time or appetite to compare models, and "good enough, out of the box" genuinely is good enough.&lt;/li&gt;
&lt;li&gt;Your volumes are small enough that convenience costs less than an engineer's afternoon.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;strong&gt;When open models win:&lt;/strong&gt;&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;You want to &lt;strong&gt;choose the model&lt;/strong&gt; that fits your tables, instead of accepting someone else's choice.&lt;/li&gt;
&lt;li&gt;A client's data-room pack is contractually &lt;strong&gt;not allowed to leave your environment&lt;/strong&gt;, which in financial work comes up far more often than people expect.&lt;/li&gt;
&lt;li&gt;You want to swap models as better ones come out, without re-integrating every time.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Running the Bake-Off Without Losing Your Weekend
&lt;/h2&gt;

&lt;p&gt;Ok, ok, I know seven models is a lot. And here's the uncomfortable truth: the only honest way to choose between them is to run several on &lt;strong&gt;your own&lt;/strong&gt; PDFs. Done the usual way, that means seven different runtimes, seven sets of dependencies and a lot of "works on my machine." That's the kind of side quest that quietly eats a sprint.&lt;/p&gt;

&lt;p&gt;This is exactly where &lt;strong&gt;&lt;a href="https://superlinked.com/" rel="noopener noreferrer"&gt;Superlinked&lt;/a&gt;'s Inference Engine (SIE)&lt;/strong&gt; shines, and honestly, it's the reason the bake-off becomes realistic at all.&lt;/p&gt;

&lt;p&gt;SIE is an inference layer built for task-specific models, and it runs the &lt;strong&gt;same engine three ways&lt;/strong&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://superlinked.com/serverless" rel="noopener noreferrer"&gt;SIE Cloud&lt;/a&gt;:&lt;/strong&gt; fully managed and priced per use. Superlinked runs the GPUs.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://superlinked.com/models" rel="noopener noreferrer"&gt;Self-hosted&lt;/a&gt;:&lt;/strong&gt; the same engine under &lt;strong&gt;Apache 2.0&lt;/strong&gt; in your own Kubernetes cluster and VPC.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Local:&lt;/strong&gt; for development and quick experiments.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;My favourite part is how boring it makes model comparison, in the best way. The SIE &lt;a href="https://superlinked.com/docs" rel="noopener noreferrer"&gt;Python SDK&lt;/a&gt; calls docling, LightOnOCR-2-1B, PaddleOCR-VL-1.5, MinerU2.5-Pro and GLM-OCR through the &lt;strong&gt;same &lt;code&gt;extract&lt;/code&gt; method&lt;/strong&gt;. Five integrations become one 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;# Illustrative only: the shape of the bake-off, not SIE's exact client API.
# Check the SIE docs for client setup, the extract signature and full model IDs.
&lt;/span&gt;&lt;span class="n"&gt;CANDIDATES&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;docling&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;            &lt;span class="c1"&gt;# SIE Cloud
&lt;/span&gt;    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;LightOnOCR-2-1B&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;    &lt;span class="c1"&gt;# SIE Cloud
&lt;/span&gt;    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;MinerU2.5-Pro&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;      &lt;span class="c1"&gt;# self-hosted catalog
&lt;/span&gt;    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;PaddleOCR-VL-1.5&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;   &lt;span class="c1"&gt;# self-hosted catalog
&lt;/span&gt;    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;GLM-OCR&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;            &lt;span class="c1"&gt;# self-hosted catalog
&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;model_id&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;CANDIDATES&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;output&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;extract&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;model_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;document&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;hardest_50_pages.pdf&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="nf"&gt;save_for_scoring&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;model_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;output&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The two cheapest candidates don't even need a GPU on your side. On &lt;a href="https://superlinked.com/serverless" rel="noopener noreferrer"&gt;SIE Cloud&lt;/a&gt; (as of 2026-09-30):&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Model on SIE Cloud&lt;/th&gt;
&lt;th&gt;Price&lt;/th&gt;
&lt;th&gt;10,000 pages&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;docling (to markdown)&lt;/td&gt;
&lt;td&gt;$0.397 / 1,000 pages&lt;/td&gt;
&lt;td&gt;$3.97&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;docling (OCR profile)&lt;/td&gt;
&lt;td&gt;$1.86 / 1,000 pages&lt;/td&gt;
&lt;td&gt;$18.60&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;LightOnOCR-2-1B&lt;/td&gt;
&lt;td&gt;$2 / 1,000 pages&lt;/td&gt;
&lt;td&gt;$20&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Under four dollars to run Docling over ten thousand pages. That's cheaper than the coffee you'll drink while reviewing the results.&lt;/p&gt;

&lt;p&gt;MinerU2.5-Pro, PaddleOCR-VL-1.5 and GLM-OCR run from the &lt;strong&gt;self-hosted catalog&lt;/strong&gt;. A small heads-up: several of them need a &lt;strong&gt;Linux machine with an NVIDIA GPU&lt;/strong&gt;, so your MacBook gets to sit this round out.&lt;/p&gt;

&lt;p&gt;What I like even more is what happens &lt;em&gt;after&lt;/em&gt; you pick a winner. On SIE, OCR, field extraction, embeddings and reranking all share the &lt;strong&gt;same client&lt;/strong&gt;. The table reader you chose lives right next to the extractor that turns cells into schema fields and the reranker that powers search. Nothing gets re-wired. And the day a client says &lt;em&gt;"this data stays in our VPC"&lt;/em&gt;, you move the same engine with the same model IDs inside their walls. No rewrite, no "the self-hosted version has different models," no drama.&lt;/p&gt;

&lt;p&gt;That's the difference between a model you tested and a model you can actually put into production.&lt;/p&gt;

&lt;h2&gt;
  
  
  Don't Trust Your Eyes: Score the Cells
&lt;/h2&gt;

&lt;p&gt;Remember the balance sheet from the beginning? It passed the eyeball test. That's precisely the problem. And as we saw with TEDS, even a great benchmark score can hide a flipped sign.&lt;/p&gt;

&lt;p&gt;So once your bake-off produces results, grade them the boring, reliable way. Take a handful of real tables, write down the correct &lt;strong&gt;value, row header, column header and sign&lt;/strong&gt; for each cell, and count how many each model gets &lt;em&gt;exactly&lt;/em&gt; right. Count sign flips separately, because those are the errors nobody spots on screen.&lt;/p&gt;

&lt;p&gt;Here's a small scorer to get you going:&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;re&lt;/span&gt;

&lt;span class="n"&gt;DASHES&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;-&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;—&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;–&lt;/span&gt;&lt;span class="sh"&gt;"&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;normalise&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;cell&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;scale&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;float&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Turn a financial-table cell into a signed number in base units.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="n"&gt;text&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;cell&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;strip&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;symbol&lt;/span&gt; &lt;span class="ow"&gt;in&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="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="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="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="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;text&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;symbol&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;""&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;text&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;re&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sub&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;r&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="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;""&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;strip&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;  &lt;span class="c1"&gt;# trailing footnote markers
&lt;/span&gt;    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;text&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;DASHES&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="mf"&gt;0.0&lt;/span&gt;  &lt;span class="c1"&gt;# a dash in a statement usually means nil
&lt;/span&gt;    &lt;span class="n"&gt;negative&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;startswith&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="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;endswith&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="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;text&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;strip&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="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;strip&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;value&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;float&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="nb"&gt;ValueError&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;
    &lt;span class="nf"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;value&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;negative&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;scale&lt;/span&gt;


&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;score_cells&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;truth&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;predicted&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;scale&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Compare cells keyed by (row header, column header). Exact match or it doesn&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;t count.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="n"&gt;exact&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;sign_flips&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;missing&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;true_raw&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;truth&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;items&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
        &lt;span class="n"&gt;expected&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;normalise&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;true_raw&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;scale&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;got&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;normalise&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;predicted&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;""&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;scale&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;got&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;missing&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;
        &lt;span class="k"&gt;elif&lt;/span&gt; &lt;span class="n"&gt;got&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;expected&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;exact&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;
        &lt;span class="k"&gt;elif&lt;/span&gt; &lt;span class="n"&gt;expected&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="nf"&gt;abs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;got&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="nf"&gt;abs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;expected&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
            &lt;span class="n"&gt;sign_flips&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;cell_accuracy&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;round&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;exact&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;truth&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;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;sign_flips&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;sign_flips&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;missing&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;missing&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;


&lt;span class="n"&gt;truth&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Revenue&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;FY2025&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;3,180&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Revenue&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;FY2024&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;2,947&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Net income (loss)&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;FY2025&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;(412)&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Net income (loss)&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;FY2024&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;118¹&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="n"&gt;predicted&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Revenue&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;FY2025&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;3,180&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Revenue&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;FY2024&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;2,947&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Net income (loss)&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;FY2025&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;412&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;   &lt;span class="c1"&gt;# brackets read as punctuation
&lt;/span&gt;    &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Net income (loss)&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;FY2024&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;1181&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;  &lt;span class="c1"&gt;# superscript read as a digit
&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="nf"&gt;score_cells&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;truth&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;predicted&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;scale&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;1_000_000&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;span class="c1"&gt;# {'cell_accuracy': 0.5, 'sign_flips': 1, 'missing': 0}
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Half the cells are wrong, one of them has the opposite sign, and on screen the table looks absolutely fine. Fifty of your ugliest pages scored like this will tell you more than any leaderboard on the internet.&lt;/p&gt;

&lt;h2&gt;
  
  
  Summary and a Useful Cheat Sheet
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;If your problem is...&lt;/th&gt;
&lt;th&gt;Try this first&lt;/th&gt;
&lt;th&gt;Then compare with&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Tables you need to defend to an auditor&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Docling&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;granite-docling-258M&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;You want the smallest model with finance-table evidence&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;granite-docling-258M&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Docling&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Notes and tables that span several pages&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;MinerU2.5-Pro&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;PaddleOCR-VL-1.5&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Skewed, warped or old scans&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;PaddleOCR-VL-1.5&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;GLM-OCR&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;You need coordinates and table HTML for a review UI&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;dots.ocr&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Docling&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;A huge archive you need to sort cheaply first&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;LightOnOCR-2-1B&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Then route tables to Docling or MinerU&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Running all of the above without seven pipelines&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;SIE&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  What This List Doesn't Cover
&lt;/h2&gt;

&lt;p&gt;In the spirit of honesty, a few things this article deliberately leaves out:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Turning tables into structured fields.&lt;/strong&gt; Reading a table correctly is step one. Mapping "Net income (loss), FY2025" into your schema, with the right period and scale, is a separate job for extraction models, and it deserves its own article.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Independent benchmarks.&lt;/strong&gt; Almost every number here comes from the model's publisher. They're useful for building a shortlist, not for crowning a winner.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Your documents.&lt;/strong&gt; No ranking, including mine, knows what your ugliest PDF looks like. Only a bake-off does.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;There's a Swahili proverb that sums up this whole article: &lt;em&gt;Mtaka cha mvunguni sharti ainame.&lt;/em&gt; Whoever wants what's under the bed has to bend down.&lt;/p&gt;

&lt;p&gt;There's no shortcut to the right OCR model for financial tables. Leaderboards will get you a shortlist, but you still have to bend down, pull out your ugliest PDFs and see who actually reads the brackets correctly.&lt;/p&gt;

&lt;p&gt;My shortlist for you: parse XBRL and HTML first, and triage your PDF pages before anything touches a GPU. For the tables that are left, test &lt;strong&gt;Docling&lt;/strong&gt; and &lt;strong&gt;granite-docling-258M&lt;/strong&gt; first, bring in &lt;strong&gt;MinerU2.5-Pro&lt;/strong&gt; or &lt;strong&gt;PaddleOCR-VL-1.5&lt;/strong&gt; for tables that span pages or scans that have seen better days, and use &lt;strong&gt;LightOnOCR-2-1B&lt;/strong&gt; to sort a big archive cheaply.&lt;/p&gt;

&lt;p&gt;And to make that bending down a lot less painful, run the whole bake-off on &lt;strong&gt;SIE&lt;/strong&gt;. Docling and LightOnOCR are a per-page API call on &lt;a href="https://superlinked.com/serverless" rel="noopener noreferrer"&gt;SIE Cloud&lt;/a&gt;, the rest are in the self-hosted catalog behind the same &lt;code&gt;extract&lt;/code&gt; method, and the winner can go straight into production, on &lt;a href="https://superlinked.com/serverless" rel="noopener noreferrer"&gt;SIE Cloud&lt;/a&gt; or inside your own VPC, without anyone rewriting a thing.&lt;/p&gt;

&lt;h2&gt;
  
  
  Final Thoughts &amp;amp; Conversation
&lt;/h2&gt;

&lt;p&gt;The most surprising thing about this space right now is how good the open options have become. There are commercially licensed models with real table evidence behind them, some small enough to run on a laptop. The hard part has shifted from &lt;em&gt;finding&lt;/em&gt; a capable model to &lt;em&gt;proving&lt;/em&gt; which one works on your documents, and that's a much better problem to have.&lt;/p&gt;

&lt;p&gt;What's the worst financial table you've ever had to extract? A note that ran for four pages, a scale line that quietly multiplied everything by a million, or brackets that vanished into thin air? Which model finally got it right? I'd love to hear your horror stories in the comments!&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A Quick Side Note:&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;I regularly write about backend performance, AI infrastructure, document pipelines and real-world engineering trade-offs. If you enjoy deep dives into systems that fail loudly instead of quietly getting things wrong, I'd love to connect and chat!&lt;/p&gt;

&lt;p&gt;You can find me on &lt;a href="https://www.linkedin.com/in/nemwel-nyandoro/" rel="noopener noreferrer"&gt;LinkedIn&lt;/a&gt;, follow my latest technical thoughts on &lt;a href="https://x.com/nemwel_bonie" rel="noopener noreferrer"&gt;Twitter/X&lt;/a&gt;, or check out my open-source projects and previous articles on &lt;a href="https://github.com/Nemwel-Boniface" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;I hope this was useful information for you. See you in my next article!&lt;/p&gt;

</description>
      <category>ai</category>
      <category>machinelearning</category>
      <category>opensource</category>
      <category>python</category>
    </item>
    <item>
      <title>Best AI Inference Platforms for Financial Document Processing in 2026: Why Superlinked SIE Tops My List</title>
      <dc:creator>Nemwel Boniface</dc:creator>
      <pubDate>Thu, 15 Oct 2026 13:51:07 +0000</pubDate>
      <link>https://dev.to/nemwelboniface/best-ai-inference-platforms-for-financial-document-processing-in-2026-why-superlinked-sie-tops-my-4kaa</link>
      <guid>https://dev.to/nemwelboniface/best-ai-inference-platforms-for-financial-document-processing-in-2026-why-superlinked-sie-tops-my-4kaa</guid>
      <description>&lt;p&gt;A production incident usually gets my attention faster than an invoice. But in financial document processing, an invoice can be a warning of its own: you're paying a model to do work that a simple parser could have handled.&lt;/p&gt;

&lt;p&gt;There's a Swahili proverb I keep coming back to whenever I see a team rush into a technical decision: &lt;em&gt;Haraka haraka haina baraka&lt;/em&gt;, hurry hurry has no blessing. In this case, the expensive mistake is picking a document-AI service because it says "PDF" on the landing page, sending every filing through it, and checking the bill only when finance comes knocking.&lt;/p&gt;

&lt;p&gt;Let's look at how to choose an inference platform for financial documents without paying to process things that were never hard to read in the first place.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fgw7r24i4duzxkznzm4wx.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fgw7r24i4duzxkznzm4wx.gif" alt="GIF: someone opening an invoice and slowly closing the laptop" width="480" height="270"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Introduction: Turning Clean Markup Into Pixels (and Back Again)
&lt;/h2&gt;

&lt;p&gt;Picture a team building a pipeline to read US annual reports. They sign up for a document-AI service, pay per-page rates, and feed it every 10-K they can find.&lt;/p&gt;

&lt;p&gt;Here's the punchline: those 10-Ks were &lt;strong&gt;born-digital HTML&lt;/strong&gt;. Every figure on the face of the financial statements was already tagged in &lt;strong&gt;Inline XBRL&lt;/strong&gt;. The service dutifully took perfectly clean, machine-readable markup, rendered it into pixels, and then used OCR to turn those pixels back into text. That's like printing an email so you can scan it and email the scan.&lt;/p&gt;

&lt;p&gt;Meanwhile, the one source that genuinely &lt;em&gt;needed&lt;/em&gt; document AI, a foreign issuer's annual report published only as a PDF, was the part that broke. A multi-page segment table split across pages and lost its header row, so every number below the page break was now an orphan with no column to belong to.&lt;/p&gt;

&lt;p&gt;Two problems, one root cause: the team picked a tool before they understood which of their documents were actually hard. So before we rank anything, let's fix that.&lt;/p&gt;

&lt;h2&gt;
  
  
  Before You Pick a Platform: Do You Even Need Document AI?
&lt;/h2&gt;

&lt;p&gt;This is the part most "best platform" listicles skip, and it's the part that saves you the most money.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;EDGAR primary documents are HTML&lt;/strong&gt;, and have been Inline XBRL HTML since the 2019 to 2021 phase-in. EX-99.1 earnings releases are HTML too. For tagged statement figures, you don't need OCR at all. You need an HTTP client:&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;requests&lt;/span&gt;

&lt;span class="c1"&gt;# The SEC asks for a descriptive User-Agent with contact details.
&lt;/span&gt;&lt;span class="n"&gt;HEADERS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;User-Agent&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Your Name your.email@example.com&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="n"&gt;cik&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;0000320193&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;  &lt;span class="c1"&gt;# Apple, zero-padded to 10 digits
&lt;/span&gt;&lt;span class="n"&gt;url&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;https://data.sec.gov/api/xbrl/companyfacts/CIK&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;cik&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;.json&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;

&lt;span class="n"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;requests&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;headers&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;HEADERS&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;timeout&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;30&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;raise_for_status&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="n"&gt;facts&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

&lt;span class="n"&gt;revenue&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;facts&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;facts&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;us-gaap&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;RevenueFromContractWithCustomerExcludingAssessedTax&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;units&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;USD&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;

&lt;span class="n"&gt;annual&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="n"&gt;fact&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;fact&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;revenue&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;fact&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;form&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;10-K&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;fact&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;file&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;fact&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;end&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;]&lt;/span&gt;

&lt;span class="c1"&gt;# Sort by filing date instead of assuming the last item is latest.
&lt;/span&gt;&lt;span class="n"&gt;annual&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sort&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="k"&gt;lambda&lt;/span&gt; &lt;span class="n"&gt;fact&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;fact&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;file&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;""&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;

&lt;span class="n"&gt;latest&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;annual&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;1&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;latest&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;end&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;latest&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;val&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
&lt;span class="c1"&gt;# Period end date and value in base units (dollars, not millions).
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No pixels, no per-page fee, and the values come back in base units, so nobody has to guess whether "394,328" means thousands or millions.&lt;/p&gt;

&lt;p&gt;There is a catch, and it matters. The &lt;code&gt;companyfacts&lt;/code&gt; and &lt;code&gt;frames&lt;/code&gt; APIs only return &lt;strong&gt;entity-wide facts on standard taxonomies&lt;/strong&gt;. Company extension tags, segment facts and member facts live in the filing's own iXBRL, which is still HTML you can parse. Same story for untagged content like MD&amp;amp;A tables and press-release exhibits: parse the HTML tables.&lt;/p&gt;

&lt;p&gt;So where does a document-AI platform actually earn its fee? Two places:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;PDF-only sources.&lt;/strong&gt; Non-US annual reports, data-room documents, client uploads, and paper-era scanned exhibits.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The chain after the text exists.&lt;/strong&gt; Pulling fields &lt;em&gt;with&lt;/em&gt; their period and scale, embedding passages for search, and reranking whatever retrieval returns.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Notice that the second one has nothing to do with OCR. That's the part people forget, and it's exactly where a single-API platform starts to pull ahead.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Scorecard: Four Questions That Actually Matter
&lt;/h2&gt;

&lt;p&gt;I ranked every platform against one question, broken into four parts:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Coverage:&lt;/strong&gt; Does it give me PDF OCR, field extraction, embeddings &lt;em&gt;and&lt;/em&gt; reranking behind one API?&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Price transparency:&lt;/strong&gt; Does each step have a published per-unit price I can put in a spreadsheet &lt;em&gt;before&lt;/em&gt; I run anything?&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Model choice:&lt;/strong&gt; Can I pick (and swap) the model at each step, or am I stuck with whatever the vendor chose?&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Data residency:&lt;/strong&gt; Is there a path to run the exact same thing inside my own VPC for client documents that must never leave?&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;One distinction before the ranking: these platforms don't all start from the same place. Some are document-processing APIs, some are ingestion platforms, and some are infrastructure for serving models you package yourself. I'm comparing them against the same practical requirements, but that doesn't make their outputs or operating models interchangeable. The right choice still depends on which parts of the pipeline you want the platform to own.&lt;/p&gt;

&lt;p&gt;Financial data makes question 4 non-negotiable more often than you'd think. Client notes, portfolio data and data-room documents frequently come with contractual "this stays in your environment" clauses, and "we'll figure out self-hosting later" is how a three-week project becomes a six-month security review.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Superlinked Inference Engine (SIE): The Whole Chain, One Client
&lt;/h2&gt;

&lt;p&gt;SIE is an inference engine built for &lt;strong&gt;task-specific models&lt;/strong&gt;, and the first thing worth understanding is that it runs three ways with the same engine underneath:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;SIE Cloud:&lt;/strong&gt; the managed service. Superlinked runs the GPUs, you call an API, you pay for what you use.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Self-hosted:&lt;/strong&gt; the same engine under &lt;strong&gt;Apache 2.0&lt;/strong&gt;, deployed into your own Kubernetes cluster in your own VPC, with Terraform modules for &lt;strong&gt;AWS, GCP and Azure&lt;/strong&gt;. Here, you operate the cluster.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Local:&lt;/strong&gt; the same models on a single NVIDIA or Apple Silicon machine with one command. Great for prototyping on your laptop before anyone approves a GPU budget.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That "same engine, same model IDs, three deployment modes" property is the quiet superpower here. The pipeline you prototype locally is the pipeline you cost on SIE Cloud, which is the pipeline you deploy into your VPC when a client contract demands it. No rewrite, no "the self-hosted edition has different models," no surprises.&lt;/p&gt;

&lt;h3&gt;
  
  
  Coverage of the Chain
&lt;/h3&gt;

&lt;p&gt;The SIE &lt;a href="https://superlinked.com/serverless" rel="noopener noreferrer"&gt;Cloud task menu&lt;/a&gt; lists &lt;strong&gt;OCR, Doc to Markdown, Doc field extraction, Structured output, Named entities, Search and Rerank&lt;/strong&gt;, each with per-model prices. For a filings pipeline, that's every step after "the PDF arrived" on one client.&lt;/p&gt;

&lt;p&gt;Here are a few of the listed prices (as of 2026-09-30):&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Task&lt;/th&gt;
&lt;th&gt;Model&lt;/th&gt;
&lt;th&gt;Price&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Doc to Markdown&lt;/td&gt;
&lt;td&gt;docling&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;$0.397&lt;/strong&gt; per 1,000 pages&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;OCR&lt;/td&gt;
&lt;td&gt;LightOnOCR-2-1B&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;$2&lt;/strong&gt; per 1,000 pages&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Field extraction&lt;/td&gt;
&lt;td&gt;Qwen3.8-27B (FP8)&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;$0.72&lt;/strong&gt; per million tokens (in and out)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Rerank&lt;/td&gt;
&lt;td&gt;Qwen3-Reranker-4B&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;$61.89&lt;/strong&gt; per million pairs&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Rerank (smaller)&lt;/td&gt;
&lt;td&gt;Qwen3-Reranker-0.6B&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;$6.63&lt;/strong&gt; per million pairs&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;That reranker price looks scary until you do the math. A top-50 rerank is 50 query-document pairs, so:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;50 pairs x ($61.89 / 1,000,000) ≈ $0.003 per query   (4B reranker)
50 pairs x ($6.63  / 1,000,000) ≈ $0.0003 per query  (0.6B reranker)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Less than a third of a cent per query for the big one. I've seen teams spend more than that on the logging for the same request.&lt;/p&gt;

&lt;h3&gt;
  
  
  Swapping Models Is a String Change
&lt;/h3&gt;

&lt;p&gt;This is the part I genuinely love. Because every step lives on the same client, changing your OCR or extraction model is a &lt;strong&gt;model ID change&lt;/strong&gt;, not a migration. That makes the single most valuable experiment in document processing, &lt;em&gt;"which model actually handles our ugly PDFs best?"&lt;/em&gt;, cheap to run.&lt;/p&gt;

&lt;p&gt;Conceptually, your pipeline config ends up looking like this:&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;# Illustrative only: the shape of the pipeline, not SIE's exact client API.
# Check the SIE docs for the real client calls and full model IDs.
&lt;/span&gt;&lt;span class="n"&gt;PIPELINE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;doc_to_markdown&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;docling&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ocr&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;LightOnOCR-2-1B&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;          &lt;span class="c1"&gt;# try PaddleOCR-VL-1.5, MinerU2.5-Pro or GLM-OCR self-hosted
&lt;/span&gt;    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;extract&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Qwen3.8-27B-FP8&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;rerank&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Qwen3-Reranker-4B&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;      &lt;span class="c1"&gt;# drop to the 0.6B if quality holds on your eval set
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Remember that foreign issuer's report with the segment table that lost its header row? This is how you fix it: run the same 50 nasty pages through three OCR models by changing one string, compare the output, and keep the winner. SIE's &lt;a href="https://superlinked.com/models" rel="noopener noreferrer"&gt;model catalog&lt;/a&gt; adds self-hosted OCR options like &lt;strong&gt;PaddleOCR-VL-1.5, MinerU2.5-Pro and GLM-OCR&lt;/strong&gt;, so you're not limited to one vendor's opinion of what a table looks like.&lt;/p&gt;

&lt;h3&gt;
  
  
  Costing It Before You Run It
&lt;/h3&gt;

&lt;p&gt;Published per-model prices let you estimate parts of the monthly bill before a single page hits a GPU. This small example calculates Doc-to-Markdown parsing and reranking only; it does not include OCR, field extraction, embeddings, infrastructure, or other operational costs. Treat it as a starting point for a cost model, not the full pipeline bill.&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;# Partial estimate only: parsing + reranking.
# Add OCR, extraction, embeddings, infrastructure, and other costs separately.
&lt;/span&gt;&lt;span class="n"&gt;PRICES&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;docling_per_1k_pages&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.397&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;rerank_4b_per_1m_pairs&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;61.89&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;rerank_06b_per_1m_pairs&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;6.63&lt;/span&gt;&lt;span class="p"&gt;,&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;monthly_cost&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pdf_pages&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;queries&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;top_k&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;50&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;reranker&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;rerank_4b_per_1m_pairs&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;parsing&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;pdf_pages&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;1_000&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;PRICES&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;docling_per_1k_pages&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="n"&gt;reranking&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;queries&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;top_k&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;1_000_000&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;PRICES&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;reranker&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;parsing&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;round&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;parsing&lt;/span&gt;&lt;span class="p"&gt;,&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;reranking&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;round&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;reranking&lt;/span&gt;&lt;span class="p"&gt;,&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;total&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;round&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;parsing&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;reranking&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;2&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="nf"&gt;monthly_cost&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;20_000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;100_000&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;span class="c1"&gt;# {'parsing': 7.94, 'reranking': 309.45, 'total': 317.39}
&lt;/span&gt;
&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;monthly_cost&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;20_000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;100_000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;reranker&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;rerank_06b_per_1m_pairs&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;span class="c1"&gt;# {'parsing': 7.94, 'reranking': 33.15, 'total': 41.09}
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;(Field-extraction tokens depend heavily on your schema and page density, so measure those on a sample and add them in.)&lt;/p&gt;

&lt;p&gt;That's the kind of number you can put in front of a manager on day one. Try doing the same thing with a vendor whose pricing page says "Contact Sales."&lt;/p&gt;

&lt;h3&gt;
  
  
  Compliance
&lt;/h3&gt;

&lt;p&gt;For the managed service, Superlinked lists &lt;strong&gt;SOC 2 Type 2&lt;/strong&gt; along with &lt;strong&gt;US or EU data residency&lt;/strong&gt;. For anything that can't leave your environment at all, you self-host the same engine in your VPC, with the same model IDs. The code is in the open-source &lt;a href="https://github.com/superlinked/sie" rel="noopener noreferrer"&gt;SIE repository&lt;/a&gt;, so your security team can actually read what they're approving.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;My honest take:&lt;/strong&gt; SIE is the only platform on this list that ticks all four boxes. OCR, extraction, embeddings and reranking on one API. A published price for every step. Open weights you choose and swap by model ID. And the same engine on SIE Cloud or in your own cluster under Apache 2.0. For a financial document pipeline, that's the production path.&lt;/p&gt;

&lt;h2&gt;
  
  
  A Quick Reality Check on Per-Page Pricing
&lt;/h2&gt;

&lt;p&gt;Here's what 10,000 PDF pages costs at listed rates across the platforms in this article. &lt;strong&gt;Important caveat:&lt;/strong&gt; these tiers don't all produce identical output (plain OCR text versus layout-aware structure versus form fields), so treat this as a sense of scale, not a benchmark. Benchmark on your own PDFs.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Platform / Tier&lt;/th&gt;
&lt;th&gt;Listed Rate&lt;/th&gt;
&lt;th&gt;10,000 Pages&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;SIE: docling (Doc to Markdown)&lt;/td&gt;
&lt;td&gt;$0.397 / 1k pages&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;$3.97&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;SIE: LightOnOCR-2-1B (OCR)&lt;/td&gt;
&lt;td&gt;$2 / 1k pages&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;$20&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Azure Read&lt;/td&gt;
&lt;td&gt;$1.50 / 1k pages&lt;/td&gt;
&lt;td&gt;$15&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Azure Layout&lt;/td&gt;
&lt;td&gt;$10 / 1k pages&lt;/td&gt;
&lt;td&gt;$100&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Google Enterprise Document OCR&lt;/td&gt;
&lt;td&gt;$1.50 / 1k pages&lt;/td&gt;
&lt;td&gt;$15&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Google Layout Parser&lt;/td&gt;
&lt;td&gt;$10 / 1k pages&lt;/td&gt;
&lt;td&gt;$100&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Textract Detect Document Text&lt;/td&gt;
&lt;td&gt;$0.0015 / page&lt;/td&gt;
&lt;td&gt;$15&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Textract Tables&lt;/td&gt;
&lt;td&gt;$0.015 / page&lt;/td&gt;
&lt;td&gt;$150&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Textract Forms&lt;/td&gt;
&lt;td&gt;$0.05 / page&lt;/td&gt;
&lt;td&gt;$500&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Reducto Parse&lt;/td&gt;
&lt;td&gt;$10 / 1k pages&lt;/td&gt;
&lt;td&gt;$100&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Unstructured (pay-as-you-go)&lt;/td&gt;
&lt;td&gt;$0.015 / page&lt;/td&gt;
&lt;td&gt;$150&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;em&gt;Prices as listed on 2026-09-30; Azure figures are US East and Textract figures are US West (Oregon), and other regions differ.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The price difference is worth investigating, but don't treat these rows as interchangeable. Doc-to-Markdown conversion, OCR, and layout-aware parsing can produce different outputs, and the right choice depends on what the downstream pipeline needs. If Markdown is sufficient for your documents, SIE's listed docling rate is an attractive starting point. If you need reliable table structure or form fields, benchmark that output directly against your requirements before comparing the price tags.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Rest of the Field
&lt;/h2&gt;

&lt;p&gt;To be fair to everyone else, these are all real, capable products. They just answer my four questions differently.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Azure AI Document Intelligence
&lt;/h3&gt;

&lt;p&gt;Formerly Form Recognizer, now listed as Azure Document Intelligence in Foundry Tools. Its standout is the &lt;strong&gt;Layout model&lt;/strong&gt;: tables come back with structure and there's no model to choose. Containers are available, and Azure's pricing page notes container pricing matches the cloud service. Embeddings (Azure OpenAI) and a semantic ranker (Azure AI Search) live in the same tenant and the same bill.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The trade-off:&lt;/strong&gt; the chain is assembled from several Azure services, each with Azure's models. If you're an all-Azure shop, that's comfortable. If you want to pick the model at each step, it's limiting.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Google Cloud Document AI
&lt;/h3&gt;

&lt;p&gt;A set of hosted processors on GCP: Enterprise Document OCR, Form Parser, Layout Parser, Custom Extractor, plus a classifier and splitter. The &lt;strong&gt;Layout Parser returns chunks&lt;/strong&gt; along with layout, so part of your retrieval prep arrives pre-done. Form Parser and Custom Extractor list at $30 per 1,000 pages up to one million pages.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The trade-off:&lt;/strong&gt; embedding and ranking are separate Vertex services, and processing and billing are on GCP. Model your tier mix carefully, because sending every page through a custom extractor costs many times what OCR does.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. Amazon Textract (with Bedrock)
&lt;/h3&gt;

&lt;p&gt;Inside an all-AWS estate, Textract is zero-ops and the security review is short because everything shares the same account and IAM. Pair it with &lt;strong&gt;Bedrock Data Automation&lt;/strong&gt; for structured output from a "blueprint" at $0.040 per page for 30 fields or fewer.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The trade-off:&lt;/strong&gt; there's no self-hosted edition, and watch the Forms tier: at $0.05 per page it's &lt;strong&gt;thirty-three times&lt;/strong&gt; the plain text rate. Flip it on for every page of a 300-page annual report and you'll feel it.&lt;/p&gt;

&lt;h3&gt;
  
  
  5. Reducto
&lt;/h3&gt;

&lt;p&gt;One API for Parse, Extract, Split, Classify and Edit, plus Studio, a visual interface a non-engineer can use to inspect results. It's strong on &lt;strong&gt;complex tables with little setup&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The trade-off:&lt;/strong&gt; on-prem and VPC deployment are Enterprise-only, you configure processing options rather than the underlying models, and embedding and reranking happen elsewhere.&lt;/p&gt;

&lt;h3&gt;
  
  
  6. Unstructured
&lt;/h3&gt;

&lt;p&gt;An ingestion and ETL platform with an Apache 2.0 open-source library. It states &lt;strong&gt;40+ connectors and 45+ file types&lt;/strong&gt;, which makes it genuinely useful upstream of an inference layer if you're pulling from shared drives, data rooms and object stores.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The trade-off:&lt;/strong&gt; you choose from the hosted models it integrates rather than arbitrary open models, reranking sits elsewhere, and in-VPC deployment is on the Business plan.&lt;/p&gt;

&lt;h3&gt;
  
  
  7. Baseten
&lt;/h3&gt;

&lt;p&gt;Managed GPU inference for models you package yourself, billed per GPU minute (an H100 is $0.10833 per minute, about $6.50 an hour) or per token on Model APIs.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The trade-off:&lt;/strong&gt; it's not priced per page and it doesn't ship document models; you package and operate them. It's the right call when you're bringing your own model and want to run it yourself.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;(Runtimes like vLLM, SGLang and TEI often sit underneath platforms like these. They're engines, not rivals, so I haven't ranked them.)&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Summary and a Useful Cheat Sheet
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Platform&lt;/th&gt;
&lt;th&gt;One API for OCR → Extract → Embed → Rerank?&lt;/th&gt;
&lt;th&gt;Published Per-Unit Prices?&lt;/th&gt;
&lt;th&gt;Choose Your Models?&lt;/th&gt;
&lt;th&gt;Self-Host in Your VPC?&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Superlinked SIE&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Yes&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Yes, per model&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Yes, open weights by model ID&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Yes, Apache 2.0&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Azure Document Intelligence&lt;/td&gt;
&lt;td&gt;Across several Azure services&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Azure's models&lt;/td&gt;
&lt;td&gt;Containers&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Google Document AI&lt;/td&gt;
&lt;td&gt;OCR/layout yes; embed and rank on Vertex&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Google's processors&lt;/td&gt;
&lt;td&gt;No (runs on GCP)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Textract + Bedrock&lt;/td&gt;
&lt;td&gt;Across two AWS services&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;AWS's models&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Reducto&lt;/td&gt;
&lt;td&gt;Parse and extract only&lt;/td&gt;
&lt;td&gt;Standard plan yes&lt;/td&gt;
&lt;td&gt;Processing options only&lt;/td&gt;
&lt;td&gt;Enterprise only&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Unstructured&lt;/td&gt;
&lt;td&gt;Up to embedding; no rerank&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Integrated hosted models&lt;/td&gt;
&lt;td&gt;Business plan&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Baseten&lt;/td&gt;
&lt;td&gt;Bring your own models&lt;/td&gt;
&lt;td&gt;Per GPU minute / token&lt;/td&gt;
&lt;td&gt;Yes, you package them&lt;/td&gt;
&lt;td&gt;Enterprise&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  What I'd Tell My Past Self
&lt;/h2&gt;

&lt;p&gt;A few honest lessons, in the spirit of not pretending anyone gets this right on the first draft:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Sort your documents before you sort your vendors.&lt;/strong&gt; HTML and XBRL first, PDFs second. The cheapest page to process is the one you never send to a model.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Price the whole chain, not just the OCR.&lt;/strong&gt; Extraction, embedding and reranking are recurring costs that scale with queries, not pages. A platform that looks cheap at step one can get expensive at step four.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Build a nasty-PDF eval set early.&lt;/strong&gt; Fifty of your ugliest pages, multi-page segment tables included. If swapping a model is a string change, there's no excuse not to test three of them.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Decide your data-residency story on day one.&lt;/strong&gt; It's much easier to choose a platform that self-hosts the same models than to retrofit "this can't leave our VPC" six months in.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Frequently Asked Questions
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Do I need document AI for 10-K and 10-Q filings?
&lt;/h3&gt;

&lt;p&gt;For the tagged statements, no. The SEC's &lt;code&gt;companyfacts&lt;/code&gt; and &lt;code&gt;frames&lt;/code&gt; APIs return entity-wide facts on standard taxonomies in base units. Company extension tags and segment or member facts need the filing's own iXBRL, which is HTML you can parse. Document AI earns its cost on PDF-only sources and on the extraction and retrieval steps after that.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why not just keep everything in Azure?
&lt;/h3&gt;

&lt;p&gt;You can, and Azure assembles the chain from Document Intelligence, Azure OpenAI and AI Search. SIE puts OCR, extraction, embedding and reranking on one API with open weights you choose, on SIE Cloud or in your own VPC. One client plus a catalog of open models is why I'd run a filings pipeline on SIE.&lt;/p&gt;

&lt;h3&gt;
  
  
  What about IFRS filers and data that can't leave our environment?
&lt;/h3&gt;

&lt;p&gt;Foreign private issuers filing 20-F or 40-F appear in the XBRL APIs under the &lt;code&gt;ifrs-full&lt;/code&gt; taxonomy, but their home-market annual reports are often PDF-only, which is exactly where OCR earns its keep. For client notes or portfolio data that must stay put, run the same SIE models self-hosted in your VPC or locally; the &lt;a href="https://superlinked.com/docs/deployment" rel="noopener noreferrer"&gt;deployment documentation&lt;/a&gt; covers the Terraform and Helm paths.&lt;/p&gt;

&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;The expensive mistake in financial document processing is rarely the model. It's sending the wrong documents to it, paying per page to read HTML that was never hard to read, and then discovering that the one PDF that actually mattered broke on a table nobody tested.&lt;/p&gt;

&lt;p&gt;SIE fixes the parts that a platform can fix. It covers OCR, field extraction, embedding and reranking behind one API, on &lt;a href="https://superlinked.com/serverless" rel="noopener noreferrer"&gt;SIE Cloud&lt;/a&gt; or self-hosted on your own GPUs under Apache 2.0. The open weights are the document models you actually put into production, and you swap them by model ID. And because every model has a published price, you can cost the whole chain against your own page and query counts before you run a single thing.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Haraka haraka haina baraka.&lt;/em&gt; Slow down long enough to look at your documents, and the right platform picks itself.&lt;/p&gt;

&lt;h2&gt;
  
  
  Final Thoughts &amp;amp; Conversation
&lt;/h2&gt;

&lt;p&gt;Financial documents are a perfect example of a problem where the tooling question is easy and the &lt;em&gt;thinking&lt;/em&gt; question is hard. Which of your documents are actually difficult? Which steps scale with pages, and which scale with queries? Which data can never leave your cluster? Answer those first, and the platform choice becomes almost boring, which is exactly how infrastructure decisions should feel.&lt;/p&gt;

&lt;p&gt;Have you built a pipeline over 10-Ks, IFRS reports or messy client PDFs? Did you get burned by a per-page bill or a table that split across pages and took its headers with it? I'd love to hear your horror stories and your favorite OCR models in the comments below!&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;A Quick Side Note:&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;I regularly write about backend performance, AI infrastructure, system reliability, and practical engineering trade-offs from the real world. If you enjoy deep dives into document pipelines, defensive engineering, or building things that don't quietly burn through a budget, I'd love to connect and chat!&lt;/p&gt;

&lt;p&gt;You can find me on &lt;a href="https://www.linkedin.com/in/nemwel-nyandoro/" rel="noopener noreferrer"&gt;LinkedIn&lt;/a&gt;, follow my latest technical thoughts on &lt;a href="https://x.com/nemwel_bonie" rel="noopener noreferrer"&gt;Twitter/X&lt;/a&gt;, or check out my open-source projects and previous articles on &lt;a href="https://github.com/Nemwel-Boniface" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Thanks for reading, and see you in the next one!&lt;/p&gt;

</description>
      <category>ai</category>
      <category>machinelearning</category>
      <category>python</category>
      <category>fintech</category>
    </item>
    <item>
      <title>Silent Data Loss in Celery + RabbitMQ on Azure Container Apps: A Queue Registration Post-Mortem</title>
      <dc:creator>Nemwel Boniface</dc:creator>
      <pubDate>Sun, 02 Aug 2026 10:53:00 +0000</pubDate>
      <link>https://dev.to/nemwelboniface/silent-data-loss-in-celery-rabbitmq-on-azure-container-apps-a-queue-registration-post-mortem-15cc</link>
      <guid>https://dev.to/nemwelboniface/silent-data-loss-in-celery-rabbitmq-on-azure-container-apps-a-queue-registration-post-mortem-15cc</guid>
      <description>&lt;p&gt;Hello friends! I'm back with another real-world debugging story from the trenches. &lt;/p&gt;

&lt;p&gt;If there's one goal every backend engineer should have,, it's building systems that fail loudly instead of quietly dropping data while returning &lt;code&gt;202 Accepted&lt;/code&gt;. Today, we're diving into a production incident that took days to track down-not because the code was wrong, but because cloud infrastructure and container entry points had a silent disagreement.&lt;/p&gt;

&lt;p&gt;Grab your coffee, and let's go hunting for ghost queues.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fw4n4n1amxdjrvvtv4107.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fw4n4n1amxdjrvvtv4107.gif" alt="Debugging distributed systems setup" width="540" height="540"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Introduction
&lt;/h2&gt;

&lt;p&gt;One of the first lessons every engineer learns moving from a monolith to microservices is that consistency stops being free. In a monolith, a write either commits inside your transaction or it doesn't; there's one database, one process, one truth. Split that same write across services and you trade that guarantee for &lt;strong&gt;eventual consistency&lt;/strong&gt;: the write will happen, just not necessarily &lt;em&gt;right now&lt;/em&gt;, and not necessarily &lt;em&gt;in the same process that accepted the request&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;Message queues are the workhorse of that tradeoff. A typical pattern looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Client → API (producer) → Message Broker → Worker (consumer) → Database
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The API accepts the request, publishes a message, and immediately returns &lt;code&gt;202 Accepted&lt;/code&gt;. Somewhere downstream, milliseconds later, usually, a worker process picks up that message and actually does the work. This is a genuinely good pattern: it decouples slow work from request latency, it lets you scale producers and consumers independently, and it gives you a natural retry boundary if something downstream is briefly unavailable.&lt;/p&gt;

&lt;p&gt;It also has a failure mode that almost never shows up in tutorials: &lt;strong&gt;what happens when the consumer isn't actually listening, and nothing tells you?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;This is the story of a bug that took days to run down, precisely because every individual piece of evidence pointed somewhere reasonable and wrong. The application code was correct. The message broker was healthy. The container was running, healthy, and logging normally. And yet, for a specific subset of operations, every single write vanished - accepted, acknowledged, never persisted, and never once producing an error anywhere in the stack.&lt;/p&gt;

&lt;p&gt;The root cause, when we finally found it, wasn't in our code at all. It was a single stale field on a cloud platform resource that nobody had thought to check.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Architecture, Briefly
&lt;/h2&gt;

&lt;p&gt;The system in question is a fairly standard FastAPI + Celery + RabbitMQ setup, deployed as three services on Azure Container Apps:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A &lt;strong&gt;Frontend/API service&lt;/strong&gt; that accepts HTTP requests and enqueues Celery tasks for anything that writes data.&lt;/li&gt;
&lt;li&gt;A &lt;strong&gt;Worker service&lt;/strong&gt; running Celery, consuming from RabbitMQ and writing to Postgres.&lt;/li&gt;
&lt;li&gt;A &lt;strong&gt;Data service&lt;/strong&gt; behind the worker, doing the actual persistence.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Celery's queueing model is simple on paper. Each task gets a name and, usually, its own dedicated queue:&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;# worker/tasks/order_tasks.py
&lt;/span&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;celery&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;shared_task&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;worker.main&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;app&lt;/span&gt;

&lt;span class="nd"&gt;@app.task&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;orders.tasks.register_order&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;bind&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;queue&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;orders.tasks.register_order&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;register_order_task&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;command&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;order_service&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;resolve&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;IOrderService&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;order_service&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;register_order&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;command&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Declaring &lt;code&gt;@app.task(...)&lt;/code&gt; registers the task in Celery's Python-level task registry the moment the module is imported. That's step one. Step two, the one that actually matters for whether messages get processed, is telling the &lt;strong&gt;worker process&lt;/strong&gt; which queues to consume from, at startup:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight docker"&gt;&lt;code&gt;&lt;span class="c"&gt;# docker/worker.dockerfile&lt;/span&gt;
&lt;span class="k"&gt;ENTRYPOINT&lt;/span&gt;&lt;span class="s"&gt; ["celery", "-A", "worker.main", "worker", \&lt;/span&gt;
            "--loglevel", "info", \
            "-Q", "orders.tasks.register_order,orders.tasks.edit_order,orders.tasks.import_order_row"]
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That &lt;code&gt;-Q&lt;/code&gt; flag is the entire contract. If a queue name isn't in that comma-separated list, the worker never opens a consumer for it, even if the task is fully defined, fully imported, and fully capable of running. Celery and RabbitMQ don't complain about this. Nothing complains about this. A producer publishing to an unconsumed queue succeeds silently, because RabbitMQ (correctly, by design) auto-declares the destination queue on first publish regardless of whether anyone is listening.&lt;/p&gt;

&lt;p&gt;That single asymmetry, &lt;em&gt;task registration&lt;/em&gt; and &lt;em&gt;queue subscription&lt;/em&gt; being two independent things that happen to usually move together, is where this entire incident lived.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Symptom
&lt;/h2&gt;

&lt;p&gt;The bug looked exactly like a straightforward, if odd, application defect:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="err"&gt;POST /consumables
{"name": "Test Item", "code": "TEST001"}

→ 202 Accepted
{"task_id": "8f3a...", "data": {"id": "e8942ca9-...", "name": "Test Item", "code": "TEST001", ...}}
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A plausible-looking response, complete with a freshly generated ID. Everything about it &lt;em&gt;looked&lt;/em&gt; like success.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="err"&gt;GET /consumables/e8942ca9-...
→ 404 Not Found
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The record simply never existed. Not eventually. Not after a retry. Not after ten seconds, or ten minutes. This affected exactly four entity types out of seventeen in the system, consistently, at 100% loss, every single write, every time, forever.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fimse5eq9szarosrsdceq.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fimse5eq9szarosrsdceq.gif" alt="When 202 Accepted lies to your face" width="480" height="358"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;No exceptions. No 5xx responses anywhere in the chain. No entries in a dead-letter queue (there wasn't one). No metric crossed a threshold, because there was no metric measuring "messages published to a queue with zero consumers." From the outside, four specific operations across the whole API had quietly stopped doing anything, and the system was structurally incapable of telling anyone.&lt;/p&gt;

&lt;h2&gt;
  
  
  Chasing Ghosts: What We Ruled Out
&lt;/h2&gt;

&lt;p&gt;Given the symptom - accept, then silently vanish- the natural first suspects are the ones every distributed-systems postmortem reaches for:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;A duplicate-write race condition.&lt;/strong&gt; Ruled out: this happened on the very first, uniquely-coded write, no concurrency involved.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;RabbitMQ policies&lt;/strong&gt; applying unexpected arguments to specific queues (this broker was shared across several unrelated services, so a stray wildcard policy from someone else's team was a real candidate). Checked directly in the RabbitMQ management UI - zero policies configured, user or operator.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Per-vhost or per-user resource limits&lt;/strong&gt; silently rejecting new queue declarations once some threshold was hit. Checked - none configured.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Stale queue/exchange metadata&lt;/strong&gt; - the theory that these specific queues had been declared at some point with incompatible arguments (different durability, different type) and were now permanently rejecting redeclaration. This one felt promising, until we tried the most direct possible test: &lt;strong&gt;restarting the entire RabbitMQ broker.&lt;/strong&gt; A full broker restart wipes any transient inconsistency and forces every queue to be redeclared from scratch by whoever connects next. The bug survived it, completely unchanged. Theory dead.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The application code itself.&lt;/strong&gt; This is the one that eventually broke the case open, precisely because it kept coming back clean. We took the exact &lt;code&gt;-Q&lt;/code&gt; queue list from the deployed Dockerfile, spun up a disposable RabbitMQ container locally, and started the worker against it with the identical code:
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker run &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="nt"&gt;--name&lt;/span&gt; repro-rabbitmq &lt;span class="nt"&gt;-p&lt;/span&gt; 5680:5672 rabbitmq:3.13-management-alpine

celery &lt;span class="nt"&gt;-A&lt;/span&gt; worker.main worker &lt;span class="nt"&gt;--loglevel&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;info &lt;span class="nt"&gt;-Q&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;cat &lt;/span&gt;queue_list.txt&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &amp;amp;

&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="s2"&gt;"exchange="&lt;/span&gt; worker_boot.log | &lt;span class="nb"&gt;wc&lt;/span&gt; &lt;span class="nt"&gt;-l&lt;/span&gt;
&lt;span class="c"&gt;# → all 67 expected queues bound cleanly, zero errors&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every single queue, including the four that were failing 100% of the time in production, bound perfectly against a clean, local broker running the exact same code. Twice we ran this experiment, from two different angles, and twice the code came back innocent.&lt;/p&gt;

&lt;p&gt;At this point we had eliminated the broker, the policies, the limits, and the code. That doesn't leave many places left to look, which was itself the clue we were missing.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fbhubxnadwqc6nqzof6wo.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fbhubxnadwqc6nqzof6wo.gif" alt="Chasing ghost bugs in RabbitMQ" width="480" height="264"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The Breakthrough: Ask the Container What It's Actually Running
&lt;/h2&gt;

&lt;p&gt;Every diagnostic tool we'd used up to that point application logs, &lt;code&gt;celery inspect registered&lt;/code&gt;, the RabbitMQ management UI, even the Dockerfile itself- describes what &lt;em&gt;should&lt;/em&gt; be true. None of them describe what the running process was &lt;em&gt;actually invoked with&lt;/em&gt;. There's a difference, and on a container orchestration platform, that difference is exactly where a bug can hide indefinitely.&lt;/p&gt;

&lt;p&gt;Azure Container Apps supports exec'ing directly into a running replica:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;az containerapp &lt;span class="nb"&gt;exec&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-n&lt;/span&gt; worker-service &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-g&lt;/span&gt; my-resource-group &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--command&lt;/span&gt; &lt;span class="s2"&gt;"/bin/bash"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And once inside, there is exactly one place that tells you the unfiltered truth about how PID 1 was started:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;cat&lt;/span&gt; /proc/1/cmdline | &lt;span class="nb"&gt;tr&lt;/span&gt; &lt;span class="s1"&gt;'\0'&lt;/span&gt; &lt;span class="s1"&gt;'\n'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The output was not what the Dockerfile said it should be.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# What the Dockerfile's ENTRYPOINT specifies:&lt;/span&gt;
celery &lt;span class="nt"&gt;-A&lt;/span&gt; worker.main worker &lt;span class="nt"&gt;--loglevel&lt;/span&gt; info &lt;span class="nt"&gt;--autoscale&lt;/span&gt; 5,1 &lt;span class="nt"&gt;-Q&lt;/span&gt; orders.tasks.register_order,orders.tasks.edit_order,...,orders.tasks.import_order_row  &lt;span class="o"&gt;[&lt;/span&gt;67 queues total]

&lt;span class="c"&gt;# What /proc/1/cmdline actually showed:&lt;/span&gt;
celery &lt;span class="nt"&gt;--app&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;worker.main worker &lt;span class="nt"&gt;--loglevel&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;info &lt;span class="nt"&gt;--autoscale&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;10,1 &lt;span class="nt"&gt;-Q&lt;/span&gt; orders.tasks.register_order,orders.tasks.edit_order,...  &lt;span class="o"&gt;[&lt;/span&gt;39 queues total]
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Different flag syntax (&lt;code&gt;--app=&lt;/code&gt; vs. &lt;code&gt;-A&lt;/code&gt;), a different autoscale value, and a drastically shorter &lt;code&gt;-Q&lt;/code&gt; list, missing every import-row queue and every queue for the four failing entities entirely. This wasn't a formatting quirk. It was a &lt;strong&gt;completely different command&lt;/strong&gt; than the one baked into the currently-deployed image.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fmc3hvr94cp3omxbkw9gl.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fmc3hvr94cp3omxbkw9gl.gif" alt="Looking at /proc/1/cmdline and seeing the unfiltered truth" width="450" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Root Cause: The Silent Platform-Level Override
&lt;/h2&gt;

&lt;p&gt;Azure Container Apps lets you set an explicit &lt;code&gt;command&lt;/code&gt; and &lt;code&gt;args&lt;/code&gt; override directly on the Container App resource, independent of whatever &lt;code&gt;ENTRYPOINT&lt;/code&gt;/&lt;code&gt;CMD&lt;/code&gt; is baked into the image. If that override is set, it wins unconditionally, regardless of what the image itself specifies.&lt;/p&gt;

&lt;p&gt;At some point, months earlier, someone had set this override, most likely when the app was first provisioned, before several of the entities (and their import queues) in this codebase existed. Since then, every deploy had used a CI/CD step that looked like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;az containerapp update &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--name&lt;/span&gt; worker-service &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--resource-group&lt;/span&gt; my-resource-group &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--image&lt;/span&gt; &lt;span class="s2"&gt;"myregistry.azurecr.io/worker-service:&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;GIT_SHA&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Only &lt;code&gt;--image&lt;/code&gt;. Never &lt;code&gt;--command&lt;/code&gt;, never &lt;code&gt;--args&lt;/code&gt;. Every single deploy since the override was first set had faithfully shipped new code - and then silently ignored it, because the platform kept running the old, frozen command instead of the new image's own entrypoint. The Dockerfile was a complete, accurate, up-to-date source of truth for a command that had never actually run in production.&lt;/p&gt;

&lt;p&gt;Confirming and fixing this took two steps. First, read the override back out in full, since a value this long is too easy to corrupt by hand-editing a form field in a portal UI:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;az containerapp show &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-n&lt;/span&gt; worker-service &lt;span class="nt"&gt;-g&lt;/span&gt; my-resource-group &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-o&lt;/span&gt; yaml &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; worker-app.yaml
&lt;span class="c"&gt;# edit the containers[0].args block directly&lt;/span&gt;
az containerapp update &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-n&lt;/span&gt; worker-service &lt;span class="nt"&gt;-g&lt;/span&gt; my-resource-group &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--yaml&lt;/span&gt; worker-app.yaml
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then - and this is the part worth remembering- &lt;strong&gt;a redeploy alone does not fix a bug like this.&lt;/strong&gt; New revisions of a Container App still boot the same overridden command unless the override itself is changed. Every restart, every scale event, every new revision from that point forward had been re-running the identical stale command. The fix isn't "redeploy"; it's "stop overriding the image's own entrypoint," or at minimum, keep the override in lockstep with the Dockerfile by hand, which is itself a second, ongoing source of risk.&lt;/p&gt;

&lt;h2&gt;
  
  
  Data Loss and the Eventual Consistency Trap
&lt;/h2&gt;

&lt;p&gt;Here's the part that matters beyond this one platform quirk: &lt;strong&gt;this failure mode is a direct, sharp-edged consequence of how "eventual" consistency actually works.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Celery's default publisher behavior auto-declares the destination queue when a message is sent, whether or not any consumer exists (&lt;code&gt;task_create_missing_queues&lt;/code&gt;, on by default). That's normally a convenience; you don't have to pre-provision every queue by hand. But it also means a producer with a perfectly correct queue name will happily publish into the void forever. RabbitMQ doesn't reject the message. It doesn't expire it by default. It just sits there, &lt;code&gt;Ready&lt;/code&gt;, unconsumed, for as long as nothing is listening, which in this case turned out to be months.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;# RabbitMQ management UI, Queues tab
Name                                   Ready  Unacked  State
orders.tasks.register_order            5      0        running
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Five messages, sitting there, &lt;code&gt;0.00/s&lt;/code&gt; deliver rate, indefinitely. That number is the whole bug, visualized: five real user actions, accepted by the API, and then never acted on again.&lt;/p&gt;

&lt;p&gt;"Eventual consistency" is a promise that a write will &lt;em&gt;eventually&lt;/em&gt; be reflected - it is not a promise about &lt;em&gt;how long&lt;/em&gt; eventually is allowed to be, and it says nothing at all about what happens if the thing that's supposed to make it happen structurally never shows up. When the consumer side of an async pipeline is silently missing, "eventually" quietly becomes "never," and the system has no vocabulary for telling you the difference. A &lt;code&gt;202 Accepted&lt;/code&gt; and a permanently-lost write look identical from the caller's side, because in both cases the only promise actually made was "we'll get to it", never "we did it."&lt;/p&gt;

&lt;p&gt;A related discovery made the same point from a different angle. While chasing this, an unrelated-looking bug turned up: certain list endpoints intermittently returned an empty &lt;code&gt;204 No Content&lt;/code&gt; even though the records genuinely existed. The real cause was a data-mapping layer raising a validation error on a &lt;code&gt;NULL&lt;/code&gt; field, which the internal HTTP client silently converted into a generic "not successful" response with no status code preserved, so what was actually a &lt;code&gt;500&lt;/code&gt; on one internal hop surfaced to the end user as an innocent-looking "no data here." Different bug, same underlying lesson: &lt;strong&gt;any layer that swallows the specific reason for a failure and reports a generic, plausible-looking result is doing your future self a disservice.&lt;/strong&gt; Silent degradation is worse than a loud failure, because a loud failure gets fixed.&lt;/p&gt;

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

&lt;p&gt;A few concrete takeaways, in rough order of how much they'd have saved us:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Verify the running process, not just the source of truth.&lt;/strong&gt; &lt;code&gt;/proc/1/cmdline&lt;/code&gt; (or your platform's equivalent, &lt;code&gt;docker inspect&lt;/code&gt;, &lt;code&gt;kubectl get pod -o yaml,&lt;/code&gt; and check the actual container spec, etc.) tells you what's &lt;em&gt;actually&lt;/em&gt; executing. A Dockerfile, a Helm chart, an IaC template - these are all descriptions of &lt;em&gt;intent&lt;/em&gt;. Only the running process tells you the truth, and on any platform that supports a command/args override independent of the image, that intent and that truth can diverge invisibly.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Avoid dual sources of truth for a container's startup command.&lt;/strong&gt; If the Dockerfile's &lt;code&gt;ENTRYPOINT&lt;/code&gt; fully specifies how to start the process, don't &lt;em&gt;also&lt;/em&gt; maintain a platform-level override of the same thing. Pick one. If a platform-level override is unavoidable for some reason, treat any deploy that doesn't also touch it as suspect.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Instrument the gap, not just the endpoints.&lt;/strong&gt; An unconsumed-message-count alert on your broker (&lt;code&gt;Ready &amp;gt; 0&lt;/code&gt; and &lt;code&gt;deliver/get ≈ 0&lt;/code&gt; for more than N minutes, per queue) would have caught this in minutes instead of days. Application-level health checks and 200-OK dashboards are blind to this entire failure class by construction, the API was never unhealthy; the worker was never crashing. The problem lived entirely in the space between two healthy-looking components.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Write a queue-parity check.&lt;/strong&gt; A five-line script that statically extracts every &lt;code&gt;queue=&lt;/code&gt; from your task decorators and diffs it against whatever's actually passed to &lt;code&gt;-Q&lt;/code&gt; at deploy time would have caught this before it ever reached production, and would catch the next instance of "we added a new entity and forgot to wire its queue" too, which, notably, is exactly what happened a second time later in this same incident, for twelve &lt;em&gt;other&lt;/em&gt; queues nobody had thought to check.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Don't trust "it's already correct" claims made against source files alone.&lt;/strong&gt; More than once in this incident, a piece of code being &lt;em&gt;correctly committed&lt;/em&gt; was mistaken for that code &lt;em&gt;actually running&lt;/em&gt; in production. Those are different facts, and conflating them is exactly the trap that let this bug hide as long as it did.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Why This Bug Was So Difficult to Find
&lt;/h2&gt;

&lt;p&gt;Looking back, what made this incident particularly difficult wasn't that any individual component was broken; it was that &lt;strong&gt;every component appeared to be working correctly when viewed in isolation.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;At different stages of the investigation, each layer gave us evidence that suggested the system was healthy:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The API accepted requests and returned &lt;code&gt;202 Accepted&lt;/code&gt; as expected.&lt;/li&gt;
&lt;li&gt;Producers successfully published messages to RabbitMQ.&lt;/li&gt;
&lt;li&gt;RabbitMQ accepted and stored every message without errors.&lt;/li&gt;
&lt;li&gt;Celery itself was healthy and running normally.&lt;/li&gt;
&lt;li&gt;Azure Container Apps reported healthy containers and successful deployments.&lt;/li&gt;
&lt;li&gt;Application logs showed no exceptions or crashes.&lt;/li&gt;
&lt;li&gt;Our deployment pipeline completed successfully, giving us confidence that the latest code was running.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of those observations were incorrect. In fact, &lt;strong&gt;every single one was true.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The problem was that the worker process was consuming &lt;strong&gt;only a subset of the queues&lt;/strong&gt; because of a stale Azure Container Apps command override. Every component behaved exactly as it had been configured to behave—the configuration itself was the problem.&lt;/p&gt;

&lt;p&gt;This is what made the incident so deceptive. We naturally tend to investigate individual systems in isolation:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Is RabbitMQ healthy?&lt;/p&gt;

&lt;p&gt;Is Celery running?&lt;/p&gt;

&lt;p&gt;Did the deployment succeed?&lt;/p&gt;

&lt;p&gt;Is the application code correct?&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The answer to every one of those questions was &lt;strong&gt;yes&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;The real failure existed &lt;strong&gt;between&lt;/strong&gt; those systems, in the assumptions that connected them.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fvdz08licg16tsjjackpv.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fvdz08licg16tsjjackpv.gif" alt="When every health check is green but data is dying" width="385" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;That's one of the hardest lessons I've learned about distributed systems: &lt;strong&gt;many production incidents aren't caused by a single failing component; they emerge from the interaction between components that are each behaving correctly according to their own configuration.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;When that happens, traditional health checks, deployment success indicators, and application logs can all tell a perfectly consistent, but incomplete story. Solving the problem requires shifting your focus from individual services to the contracts and assumptions that connect them.&lt;/p&gt;

&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;None of the individual technologies here were at fault. Celery did exactly what it was told. RabbitMQ did exactly what it was told. The application code, when we finally proved it beyond doubt, was correct the entire time. The failure lived in the seam between the deployment platform and the container image, a place none of our existing tooling was looking, because none of our existing tooling had a reason to distrust it.&lt;/p&gt;

&lt;p&gt;That's the real lesson of eventual consistency in a microservice architecture: decoupling a producer from a consumer buys you resilience against the consumer being &lt;em&gt;slow&lt;/em&gt;. It buys you nothing against the consumer being &lt;em&gt;silently absent&lt;/em&gt;, and the more hops your architecture has, the more places that absence can hide. The fix, in the end, wasn't a code change at all. It was asking the one question none of our monitoring had ever thought to ask: is the thing that's running actually the thing we think we deployed?&lt;/p&gt;

&lt;p&gt;If you've hit something similar- a queue, a topic, a subscription that looked perfectly configured everywhere except in the one place that mattered- I'd genuinely like to hear about it in the comments.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fw8ozcm1sjpe4y7lh5zeo.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fw8ozcm1sjpe4y7lh5zeo.gif" alt="Until the next production post-mortem" width="270" height="480"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Final Thoughts &amp;amp; Conversation
&lt;/h2&gt;

&lt;p&gt;Distributed systems give us incredible scale and decoupling, but they trade away obvious failure modes. When your health checks, application logs, and deployment pipelines all report 100% green, the hardest bugs to solve are the ones hiding in the seams between your platform configuration and your code.&lt;/p&gt;

&lt;p&gt;Have you ever ran into a bug where an infrastructure override or an auto-declaring broker silently ate your data? How do you handle queue parity and container command parity in your CI/CD pipelines? I'd love to hear your horror stories and prevention strategies in the comments below!&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;A Quick Side Note:&lt;/strong&gt; &lt;br&gt;
I regularly write about backend performance, queue architectures, system reliability, and real-world debugging post-mortems. If you enjoy deep-diving into distributed systems, defensive engineering, or API architecture, I'd love to connect and chat!&lt;/p&gt;

&lt;p&gt;You can find me on &lt;a href="https://www.linkedin.com/in/nemwel-nyandoro/" rel="noopener noreferrer"&gt;LinkedIn&lt;/a&gt;, follow my latest technical thoughts on &lt;a href="https://x.com/nemwel_bonie" rel="noopener noreferrer"&gt;Twitter/X&lt;/a&gt;, or check out my open-source projects and previous articles on &lt;a href="https://github.com/Nemwel-Boniface" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Thanks for reading, and see you in the next post-mortem!&lt;/p&gt;

</description>
      <category>rabbitmq</category>
      <category>celery</category>
      <category>distributedsystems</category>
      <category>azure</category>
    </item>
    <item>
      <title>Rails Authorization Beyond Models: Securing Dashboards and Service Controllers with CanCanCan</title>
      <dc:creator>Nemwel Boniface</dc:creator>
      <pubDate>Fri, 06 Feb 2026 08:44:00 +0000</pubDate>
      <link>https://dev.to/nemwelboniface/rails-authorization-beyond-models-securing-dashboards-and-service-controllers-with-cancancan-4fgj</link>
      <guid>https://dev.to/nemwelboniface/rails-authorization-beyond-models-securing-dashboards-and-service-controllers-with-cancancan-4fgj</guid>
      <description>&lt;p&gt;Hello friends! I’m back with more Ruby on Rails concepts I refreshed this week to stay productive and avoid the social media "doom scroll." 2026 is the year we kill the bad habits hindering our potential, and for me, that means trading the doom-scroll for the docs.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fru2iii2laoal0wbz6hn3.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fru2iii2laoal0wbz6hn3.gif" alt="Welcome to A Guide to Rails Authorization Beyond Models: Securing Dashboards and Service Controllers with CanCanCan" width="498" height="362"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Introduction - Authentication and Authorization. What are these?
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fvimsh2huuip3nz4g969x.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fvimsh2huuip3nz4g969x.gif" alt="Authentication and Authorization. What are these?" width="480" height="270"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Authentication&lt;/em&gt; and &lt;em&gt;authorization&lt;/em&gt; are often confused, and many people think they are the same, but they are not. Similarly, their ease of implementation is often confused. You might think they are easy to integrate. But are they really? Let me explain.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://www.cloudflare.com/learning/access-management/what-is-authentication/" rel="noopener noreferrer"&gt;Authentication&lt;/a&gt;, which is verifying who you are, is the easy part. A quick Devise gem install and a migration, and your system knows its users.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://auth0.com/intro-to-iam/what-is-authorization" rel="noopener noreferrer"&gt;Authorization&lt;/a&gt;, which decides what they can do, is where the mess begins. Most of us start with &lt;a href="https://github.com/CanCanCommunity/cancancan" rel="noopener noreferrer"&gt;CanCanCan&lt;/a&gt; basics: you add &lt;code&gt;load_and_authorize_resource&lt;/code&gt; to a &lt;code&gt;PostsController&lt;/code&gt;, and magic happens. Admins edit, users read. Life is good.&lt;/p&gt;

&lt;p&gt;But what happens when you have a controller that doesn't map neatly to a database table? Think about a &lt;code&gt;DashboardController&lt;/code&gt; or a &lt;code&gt;SearchController&lt;/code&gt;. There is no &lt;strong&gt;"Dashboard"&lt;/strong&gt; table in your database.&lt;/p&gt;

&lt;p&gt;Suddenly, the magic breaks and reality sinks in. You start getting confusing error messages or, even worse, silent security gaps that only appear once a user accidentally accesses classified data.&lt;/p&gt;

&lt;p&gt;In this article, we’ll explore how to handle &lt;em&gt;authorization&lt;/em&gt; when you need to step outside the standard model framework, specifically using tools like &lt;code&gt;authorize_resource class: false&lt;/code&gt; to keep your "non-model" controllers secure.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F2goy52wsc59wxg0q25zu.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F2goy52wsc59wxg0q25zu.gif" alt="Lets begin A Guide to Rails Authorization Beyond Models: Securing Dashboards and Service Controllers with CanCanCan" width="480" height="270"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The Foundation: The Rulebook (Ability.rb)
&lt;/h2&gt;

&lt;p&gt;Before we touch our controllers, we have to understand the Source of Truth. In &lt;em&gt;CanCanCan&lt;/em&gt;, your &lt;code&gt;app/models/ability.rb&lt;/code&gt; file is the rulebook. Everything we do in the rest of the app is just asking this one file a single question: "Is this user allowed to do X to Y?". Let me show you the anatomy of one below:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;class Ability
  include CanCan::Ability

  def initialize(user)
    user ||= User.new # guest user

    if user.admin?
      can :manage, :all
    else
      # Scenario A: Database Model Permission
      # Users can manage their own posts
      can :manage, Post, user_id: user.id

      # Scenario B: Abstract Permission
      # Users can read the dashboard, even though there is no Dashboard model
      can :read, :dashboard 
    end
  end
end
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Pay close attention to &lt;strong&gt;Scenario B&lt;/strong&gt;. Notice how we defined that permission using a symbol (&lt;code&gt;:dashboard&lt;/code&gt;) instead of a class name like &lt;code&gt;Post&lt;/code&gt;. In Rails, we are used to everything being linked to a database table. But here, we are telling the rulebook that "Dashboard" is just a concept, a place the user can go. Understanding this shift from Classes to Symbols is the key to solving our future &lt;strong&gt;"Model Not Found"&lt;/strong&gt; headaches.&lt;/p&gt;

&lt;h2&gt;
  
  
  The "Secure by Default" Mindset
&lt;/h2&gt;

&lt;p&gt;Before we look at specific tools, we need to set a baseline. In a professional application, you shouldn't have to remember to add security; security should be there by default. You should only have to turn it off when you explicitly mean to, in very rare situations. In your ApplicationController, I recommend adding this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;class ApplicationController &amp;lt; ActionController::Base
  # Throw an error if a controller action runs without checking authorization first.
  check_authorization unless: :devise_controller?
end
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Think of this as your &lt;em&gt;Authorization&lt;/em&gt; Alarm System. If you create a new controller and forget to define any permissions, Rails will yell at you with a &lt;code&gt;CanCan::AuthorizationNotPerformed&lt;/code&gt; error, which is much better to have your app crash during development because you forgot a security check than to have it silently expose private data in production. We skip &lt;code&gt;devise_controller?&lt;/code&gt; because Devise handles its own "Who are you?" logic, and we don't want to get locked out of the login page!&lt;/p&gt;

&lt;p&gt;Now that the alarm system is armed, let me show you how we satisfy it in different scenarios below:&lt;/p&gt;

&lt;h2&gt;
  
  
  Scenario 1: The Standard CRUD Resource (This is the Happy Path)
&lt;/h2&gt;

&lt;p&gt;Imagine you have a &lt;em&gt;Product&lt;/em&gt; model and a standard &lt;code&gt;ProductsController&lt;/code&gt; to handle the basics: looking at a list, viewing a single item, or editing details. The Solution would be to add the &lt;code&gt;load_and_authorize_resource&lt;/code&gt; at the top of the controller. This will then tell &lt;code&gt;CanCanCan: _"Hey, look at the controller name (Products), find the matching model (&lt;/code&gt;Product&lt;code&gt;), load the data into a variable (like&lt;/code&gt;@product`), and check the Rulebook to see if this user is allowed to be here."_&lt;/p&gt;

&lt;p&gt;You are to use this ideally 90% of the time if your controller maps directly to a database table and performs standard RESTful actions. Example:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F3iskt3r6zonuaca2e3bt.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F3iskt3r6zonuaca2e3bt.png" alt="The Standard CRUD Resource" width="800" height="560"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The beauty here is Clarity. By moving the data loading and the security check to a single line at the top, your controller actions stay "pure." They only focus on what happens after the door has been opened.&lt;/p&gt;

&lt;h2&gt;
  
  
  Scenario 2: The "Service" Controller (When there is no Database Model)
&lt;/h2&gt;

&lt;p&gt;This is where the confusion usually starts. You have a controller that performs a service-like a &lt;code&gt;DashboardsController&lt;/code&gt;, a &lt;code&gt;SearchController&lt;/code&gt;, or a &lt;code&gt;ReportsController&lt;/code&gt;. If you check your database, you will find that there is no &lt;code&gt;"Dashboards"&lt;/code&gt; table. Dashboard here is just a page that aggregates data from other places.&lt;/p&gt;

&lt;p&gt;If you try to use ur favourite &lt;code&gt;load_and_authorize_resource&lt;/code&gt; here, &lt;em&gt;CanCanCan&lt;/em&gt; will go looking for a &lt;code&gt;Dashboard&lt;/code&gt; model, fail to find it, and crash your app because it does not exist. The solution is using &lt;code&gt;authorize_resource class: false&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;This tells &lt;em&gt;CanCanCan&lt;/em&gt;: &lt;em&gt;"I need to secure this controller, but stop looking for a matching database class. Just check if the user is allowed to access the concept of this controller."&lt;/em&gt;. When you set &lt;code&gt;class: false&lt;/code&gt;, &lt;em&gt;CanCanCan&lt;/em&gt; converts the controller name into a symbol (e.g., &lt;code&gt;DashboardsController&lt;/code&gt; becomes &lt;code&gt;:dashboard&lt;/code&gt;) and checks your Rulebook for that specific symbol. This is best used whenever your controller represents a concept, a process, or a service rather than a specific row in a database table.&lt;br&gt;
Example (Recall our &lt;code&gt;Ability.rb&lt;/code&gt; had &lt;code&gt;can :read, :dashboard&lt;/code&gt;.):&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fpnlhgn6hpxbqs8blvrwj.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fpnlhgn6hpxbqs8blvrwj.png" alt="The " width="799" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;If a user tries to access this page without the &lt;code&gt;can :read, :dashboard&lt;/code&gt; rule in their &lt;code&gt;ability.rb&lt;/code&gt;, they’ll get an &lt;em&gt;"Access Denied"&lt;/em&gt; error. With this, you get the same level of security as a standard model, but without the database baggage.&lt;/p&gt;

&lt;h2&gt;
  
  
  Scenario 3: The Complex or Expensive Action
&lt;/h2&gt;

&lt;p&gt;Sometimes, checking permissions at the very start of a request is too "blunt". Imagine you have an action that kicks off a heavy background job or calls an external API that charges you per request. You ideally don't want to waste any CPU cycles or money until you are 100% sure the user has the right to be there. The solution is the explicit inline &lt;code&gt;authorize!&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;This is the manual approach, and instead of letting the controller handle security automatically at the top, you take the wheel and tell &lt;em&gt;CanCanCan&lt;/em&gt; exactly when and what to check inside the method itself.&lt;/p&gt;

&lt;p&gt;Situations when you are supposed to use this include:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;When your logic is too dynamic for the standard helpers.&lt;/li&gt;
&lt;li&gt;When you need to do some "cheap" setup (like checking params) before doing an "expensive" authorization check.&lt;/li&gt;
&lt;li&gt;When your action handles multiple different types of objects at once.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Example usage:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Ffxdao3gn3pj70ypvjz6h.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Ffxdao3gn3pj70ypvjz6h.png" alt="The Complex or Expensive Action" width="800" height="632"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;By using &lt;code&gt;authorize!&lt;/code&gt; manually, you ensure that your expensive code is protected by a high-security gate. If the user isn't authorized, the method stops right there and raises an exception before the &lt;code&gt;HeavyReportingJob&lt;/code&gt; ever gets queued.&lt;/p&gt;

&lt;h3&gt;
  
  
  Summary and a useful Cheat Sheet
&lt;/h3&gt;

&lt;p&gt;Choosing the right tool comes down to two questions: &lt;strong&gt;"Does this match a database table?"&lt;/strong&gt; and &lt;strong&gt;"When do I need the check to happen?"&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;Scenario&lt;/th&gt;
&lt;th&gt;Mapped to DB?&lt;/th&gt;
&lt;th&gt;Tool to Use&lt;/th&gt;
&lt;th&gt;The "Why"&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;strong&gt;Standard RESTful&lt;/strong&gt; (Posts, Users)&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Yes&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;load_and_authorize_resource&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;The magic bullet. Loads data and checks rules automatically.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;strong&gt;Service/Concept&lt;/strong&gt; (Dashboard, Search)&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;No&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;authorize_resource class: false&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Authorizes against a &lt;strong&gt;symbol&lt;/strong&gt; so Rails doesn't look for a missing class.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Complex/Expensive Actions&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Maybe&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Explicit &lt;code&gt;authorize!&lt;/code&gt; in the action&lt;/td&gt;
&lt;td&gt;Gives you total control. Best for guarding expensive API calls or background jobs.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Ftgeoxhqj5nw5xz75b1z2.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Ftgeoxhqj5nw5xz75b1z2.gif" alt="Now the different approaches to authorisation start to make sense" width="480" height="270"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://www.health.harvard.edu/mind-and-mood/doomscrolling-dangers" rel="noopener noreferrer"&gt;Doom-scrolling&lt;/a&gt; gives you a dopamine hit that lasts five seconds. Solving a tricky authorization bug gives you a secure, professional application that stays protected while you sleep.&lt;/p&gt;

&lt;p&gt;By moving past the basic "magic" and mastering tools like &lt;code&gt;class: false&lt;/code&gt; and explicit &lt;code&gt;authorize!&lt;/code&gt; calls, you stop fighting the framework and start making it work for your specific needs. In 2026, we aren't just building apps that "work"; we are building apps that are secure, intentional, and architected for the long haul.&lt;/p&gt;

&lt;p&gt;Next time Rails "yells" at you about a missing model, don't reach for your phone to scroll. Reach for your &lt;code&gt;ability.rb&lt;/code&gt; and check if you're trying to authorize a class that doesn't exist.&lt;/p&gt;

&lt;p&gt;Do you prefer the "magic" of automated authorization, or are you a "manual control" developer who likes to see every security gate in your code? I’d love to hear your horror stories about AccessDenied errors in the comments!&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fy806dk56b318m3m1gs2m.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fy806dk56b318m3m1gs2m.gif" alt="I will be back with more technical articles" width="480" height="309"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A Quick Side Note:&lt;/strong&gt; While I’m heads-down building this search engine, I’m also looking to pitch in on new projects. If you’re looking for a mid-level Rails engineer with solid JavaScript experience who prioritizes security, authorization logic, and building defensive, scalable systems, I'd love to chat. You can find me on [&lt;a href="https://www.linkedin.com/in/nemwel-nyandoro/" rel="noopener noreferrer"&gt;LinkedIn&lt;/a&gt;/&lt;a href="https://x.com/nemwel_bonie" rel="noopener noreferrer"&gt;Twitter&lt;/a&gt;] or check out my other work &lt;a href="https://github.com/Nemwel-Boniface" rel="noopener noreferrer"&gt;here&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;I hope this was useful information for you. See you in my next article.&lt;/p&gt;

</description>
      <category>rails</category>
      <category>ruby</category>
      <category>webdev</category>
      <category>softwareengineering</category>
    </item>
    <item>
      <title>What Happens When a User Deletes Their Account? A Guide to Rails ActiveRecord dependent: Strategies</title>
      <dc:creator>Nemwel Boniface</dc:creator>
      <pubDate>Tue, 20 Jan 2026 06:25:43 +0000</pubDate>
      <link>https://dev.to/nemwelboniface/what-happens-when-a-user-deletes-their-account-a-guide-to-rails-activerecord-dependent-strategies-1279</link>
      <guid>https://dev.to/nemwelboniface/what-happens-when-a-user-deletes-their-account-a-guide-to-rails-activerecord-dependent-strategies-1279</guid>
      <description>&lt;p&gt;Hello friends! I'd like to show you Ruby on Rails concepts I refreshed this week to Avoid Social Media Doom Scrolling.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fru2iii2laoal0wbz6hn3.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fru2iii2laoal0wbz6hn3.gif" alt="Welcome to A Guide to Rails ActiveRecord dependent: Strategies for Ruby on Rails Developers" width="498" height="362"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Introduction
&lt;/h2&gt;

&lt;p&gt;We’ve all been there. You’re at the end of a high-intensity coding session, or maybe you're in the middle of a &lt;a href="https://blogs.uoc.edu/mel/the-pomodoro-method-maximizing-productivity-with-interval-time/" rel="noopener noreferrer"&gt;Pomodoro&lt;/a&gt; break. Your brain is fried, and the easiest "hit" is a social media feed scroll. Suddenly, you're deep into &lt;a href="https://www.health.harvard.edu/mind-and-mood/doomscrolling-dangers" rel="noopener noreferrer"&gt;doom-scrolling&lt;/a&gt;, watching phenomenal creators like Simon Squibb ask people, "What’s your dream?" (He is honestly my favourite creator right now, and his videos are raw and authentic and a whole vibe! Who’s your favorite creator lately?)&lt;/p&gt;

&lt;p&gt;This week, I decided to fight the scroll. I closed the "Trending" tab and opened the Rails Documentation instead. My goal was to master &lt;a href="https://guides.rubyonrails.org/association_basics.html" rel="noopener noreferrer"&gt;ActiveRecord Association&lt;/a&gt; Dependencies.&lt;/p&gt;

&lt;p&gt;If you're building a user-centric app, like the "Google-style" search engine I’m currently architecting, you quickly realize that choosing how a record "dies" is just as critical as how it lives. In a search engine that tracks saved queries, leads, and API keys, our strategy has to balance two things: User Privacy (deleting personal data) and Business Continuity (preserving lead data for analytics).&lt;/p&gt;

&lt;p&gt;Here is how I structured my models to ensure my database doesn't become a "ghost town" of orphaned records.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. The Clean Sweep: &lt;code&gt;dependent: :destroy&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;In my app, when a user deletes their account, I want their Saved Searches to vanish. These are private and hold zero value once the owner is gone.&lt;/p&gt;

&lt;p&gt;I chose &lt;code&gt;:destroy&lt;/code&gt; because it’s the "polite" way to say goodbye. Unlike a cold database delete, :destroy instantiates each record and triggers its specific callbacks. If you have logic that says "Notify the search engine to de-index this query when deleted," this is the only way to ensure that "cleanup" logic actually executes.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;User&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="no"&gt;ApplicationRecord&lt;/span&gt;
  &lt;span class="n"&gt;has_many&lt;/span&gt; &lt;span class="ss"&gt;:saved_searches&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;dependent: :destroy&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  2. The Bulk Cleanup: &lt;code&gt;dependent: :delete_all&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;Sometimes, you don’t need a polite goodbye; you need raw speed. Imagine a user has millions of Audit Logs. Calling &lt;code&gt;:destroy&lt;/code&gt; would try to load every single log into Ruby memory, a surefire way to crash your server or hang your database.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;:delete_all&lt;/code&gt; skips the Rails middleware entirely. It sends a single, sharp SQL command directly to the database. This, however, has a Trade-off: If your AuditLog has a before_destroy hook that cleans up a file on Amazon S3, it will not run. Use this only for "passive" data where speed is the priority over side effects.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;User&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="no"&gt;ApplicationRecord&lt;/span&gt;
  &lt;span class="c1"&gt;# If we had millions of log entries, we didn't need to process&lt;/span&gt;
  &lt;span class="n"&gt;has_many&lt;/span&gt; &lt;span class="ss"&gt;:audit_logs&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;dependent: :delete_all&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  3. The "Legacy" Handover: &lt;code&gt;dependent: :nullify&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;This was the trickiest one for me to grasp, but it’s vital for B2B apps. In our search engine scenario, if a User generates a Lead and then deletes their account, the business still needs that Lead record to calculate conversion rates.&lt;/p&gt;

&lt;p&gt;If I delete the lead, I break the business analytics. By using nullify, the user_id on the Lead becomes NULL, but the record itself stays on the "shelf." The user’s privacy is respected (their name is gone), but the business value is preserved.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;User&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="no"&gt;ApplicationRecord&lt;/span&gt;
  &lt;span class="n"&gt;has_many&lt;/span&gt; &lt;span class="ss"&gt;:leads&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;dependent: :nullify&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  4. The Soft Block: &lt;code&gt;dependent: :restrict_with_error&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;I want you to think of this as the "Safety Valve." Sometimes, a user cannot be allowed to leave yet. In my app, if a user has Active API Keys, letting them delete their account would leave external integrations in a broken state.&lt;/p&gt;

&lt;p&gt;Instead of a hard crash, &lt;code&gt;:restrict_with_error&lt;/code&gt; adds a message to the errors object. This allows me to tell the user in the UI: &lt;strong&gt;&lt;em&gt;"You can't close your account while you have active API keys. Please disable them first."&lt;/em&gt;&lt;/strong&gt; It’s a UX-friendly way to protect critical system resources.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;User&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="no"&gt;ApplicationRecord&lt;/span&gt;
  &lt;span class="c1"&gt;# "You can't leave us while you still have an active API Key!"&lt;/span&gt;
  &lt;span class="n"&gt;has_many&lt;/span&gt; &lt;span class="ss"&gt;:api_keys&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;dependent: :restrict_with_error&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  5. The Hard Guard: &lt;code&gt;dependent: :restrict_with_exception&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;This is the ultimate safety net for financial data. I use this for Billing Subscriptions. If the system attempts to delete a user who still has an active billing record, Rails will raise a hard &lt;code&gt;ActiveRecord::DeleteRestrictionError&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;This isn't for a &lt;strong&gt;"nice UI message."&lt;/strong&gt; This is for your &lt;strong&gt;"Security-First"&lt;/strong&gt; logic, a last line of defense to ensure your financial integrity is never compromised by a stray user.destroy call.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;User&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="no"&gt;ApplicationRecord&lt;/span&gt;
  &lt;span class="n"&gt;has_many&lt;/span&gt; &lt;span class="ss"&gt;:billing_subscriptions&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;dependent: :restrict_with_exception&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Short Recap
&lt;/h3&gt;

&lt;p&gt;When a user leaves, not all data should be treated the same. Privacy, performance, and business continuity pull in different directions. Pick &lt;code&gt;:destroy&lt;/code&gt; when cleanup must run, &lt;code&gt;:delete_all&lt;/code&gt; for raw speed, &lt;code&gt;:nullify&lt;/code&gt; to keep business artifacts, and &lt;code&gt;:restrict_with_*&lt;/code&gt; to block dangerous deletions.&lt;/p&gt;

&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://www.health.harvard.edu/mind-and-mood/doomscrolling-dangers" rel="noopener noreferrer"&gt;Doom-scrolling&lt;/a&gt; gives you a dopamine hit that lasts five seconds. Solving a database integrity problem gives you a stable application that lasts years.&lt;/p&gt;

&lt;p&gt;By strategically picking between &lt;code&gt;:destroy&lt;/code&gt;, &lt;code&gt;:nullify&lt;/code&gt; and &lt;code&gt;:restrict&lt;/code&gt;, I’ve ensured that my system handles user churn like a pro. My advice? Next time you’re bored, don't check the "Trending" tab. Check your schema.rb. There is always a relationship that could be handled more gracefully.&lt;/p&gt;

&lt;p&gt;Which dependent strategy do you find yourself reaching for most often? Are you a "Clean Sweep" developer who keeps a pristine database, or do you lean heavily on &lt;code&gt;:nullify&lt;/code&gt; to protect business analytics? I’d love to hear how you handle the "afterlife" of your data in the comments!&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fy806dk56b318m3m1gs2m.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fy806dk56b318m3m1gs2m.gif" alt="I will be back with more technical articles" width="480" height="309"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A Quick Side Note:&lt;/strong&gt; While I’m heads-down building this search engine, I’m also looking to pitch in on new projects. If you’re looking for a mid-level Rails engineer with solid JavaScript experience who cares about building defensive, scalable systems, I’d love to chat. You can find me on [&lt;a href="https://www.linkedin.com/in/nemwel-nyandoro/" rel="noopener noreferrer"&gt;LinkedIn&lt;/a&gt;/&lt;a href="https://x.com/nemwel_bonie" rel="noopener noreferrer"&gt;Twitter&lt;/a&gt;] or check out my other work &lt;a href="https://github.com/Nemwel-Boniface" rel="noopener noreferrer"&gt;here&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;I hope this was useful information for you. See you in my next article.&lt;/p&gt;

</description>
      <category>database</category>
      <category>rails</category>
      <category>ruby</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>Architecture Backwards: Engineering a Self-Defending System Before the UI Arrives</title>
      <dc:creator>Nemwel Boniface</dc:creator>
      <pubDate>Fri, 09 Jan 2026 07:28:31 +0000</pubDate>
      <link>https://dev.to/nemwelboniface/architecture-backwards-engineering-a-self-defending-system-before-the-ui-arrives-4ohg</link>
      <guid>https://dev.to/nemwelboniface/architecture-backwards-engineering-a-self-defending-system-before-the-ui-arrives-4ohg</guid>
      <description>&lt;p&gt;Happy New Year!&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fjov1i4vokd7eje74ls4a.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fjov1i4vokd7eje74ls4a.gif" alt="Happy new year as we aim to learn how to architect systems starting from the backend to the front end" width="500" height="375"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;As we kick off 2026, many of us are starting new projects or refining old ones. This year, I’ve decided to challenge the "industry standard" of &lt;a href="https://www.atlassian.com/agile/product-management/minimum-viable-product" rel="noopener noreferrer"&gt;MVP&lt;/a&gt; development. Instead of racing to build a login page, I’m spending my time in the trenches of the core engine.&lt;/p&gt;

&lt;h2&gt;
  
  
  Introduction
&lt;/h2&gt;

&lt;p&gt;In the rush to launch a Minimum Viable Product (MVP), the standard advice is almost always: Start with the User. Build the login page, the profile, and the "Join Now" button. We often prioritise Authentication and Authorization, then move to the core logic, the actual reason the product exists, nearly as an afterthought.&lt;/p&gt;

&lt;p&gt;This approach works, and it’s the industry standard. But what if I tell you there’s a more resilient way to architect a system? One that isn't as glamorous or visually aesthetic, but is infinitely more stable?&lt;/p&gt;

&lt;p&gt;Lately, I’ve been building a new system where I’ve intentionally flipped the script. I am working backwards, starting with the "brain" of the engine rather than the face of the app. I am two weeks into development, and I haven't even touched a User module. Instead, I’ve spent my time building Self-Defence Mechanisms and &lt;a href="https://www.splunk.com/en_us/blog/learn/what-is-telemetry.html" rel="noopener noreferrer"&gt;Telemetry&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fe2dihjcqvegkfeypu4zz.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fe2dihjcqvegkfeypu4zz.gif" alt="Urging my readers to stay with me so that I can keep explaining to them why we need to architect systems starting from the backend to the front end" width="600" height="600"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Hear me out, while authentication and authorisation in your system is vital, we have battle-tested gems like Devise and CanCanCan that can be dropped in later and will solve this for you in minutes. The core health of your system, however, cannot be "dropped in" as an afterthought and therefore needs a complete system architecture shift. Here is why the most scalable systems are built from the inside out using what I call the &lt;strong&gt;&lt;em&gt;Sensor &amp;amp; Suitcase&lt;/em&gt;&lt;/strong&gt; pattern, which I will introduce in the second section below.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. The "Horizontal" Nature of Users
&lt;/h2&gt;

&lt;p&gt;When working with a modern Ruby on Rails application, the User resource can be treated as a horizontal layer. I want you to think of the User layer as the front door to your house. It’s how people get in, and it’s what they see first. But before I invite guests over, I want to make sure the foundation is solid and the electricity is wired correctly. Because Rails emphasises &lt;strong&gt;&lt;a href="https://rubyonrails.org/doctrine#convention-over-configuration" rel="noopener noreferrer"&gt;Convention over Configuration&lt;/a&gt;&lt;/strong&gt; architecture, adding a &lt;em&gt;user_id&lt;/em&gt; to a record later is often just a simple migration away. I have the expensive luxury of treating "Identity" as a pluggable module because the framework and the core language, Ruby, are &lt;a href="https://rubyonrails.org/doctrine" rel="noopener noreferrer"&gt;designed for developer happiness&lt;/a&gt;. I am actually happy working with this stack for the project.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fg11n0v8rcu434m4kbcvn.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fg11n0v8rcu434m4kbcvn.gif" alt="Adding the user resource is an easy plug in and this is why we need to architect systems starting from the backend to the front end" width="500" height="500"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;However, when we talk about the core engine, the part that actually does the work, this should be seen as vertical. If that engine is fragile, it won't matter how many users you have or how beautiful your login page is.&lt;/p&gt;

&lt;p&gt;If your core system cannot handle the "real world" problems that come with scale, such as &lt;strong&gt;race conditions, API rate limits, or cache stampedes&lt;/strong&gt;, the system will collapse under the weight of its own success.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fcv1285p59urr5uye51nq.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fcv1285p59urr5uye51nq.gif" alt="The reason why I am describing this new way will start to make sense as I go on and it will slowly be clear why we need to architect systems starting from the backend to the front end" width="480" height="270"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  1.1 The Tortoise and the API
&lt;/h3&gt;

&lt;p&gt;This might feel like a re-invention of the story of the &lt;em&gt;Hare&lt;/em&gt; and the &lt;em&gt;Tortoise&lt;/em&gt;. The &lt;em&gt;"Hare"&lt;/em&gt; approach is to race toward a UI, a &lt;em&gt;"Join Now"&lt;/em&gt; button, and a beautifully designed user dashboard. It looks fast, but it’s tripping over every unhandled exception along the way.&lt;/p&gt;

&lt;p&gt;By building &lt;em&gt;"Backwards,"&lt;/em&gt; I am the &lt;em&gt;Tortoise&lt;/em&gt;. I am building a system that knows how to protect itself first. I am ensuring that when the &lt;em&gt;"Hare"&lt;/em&gt; (the User) finally arrives, the ground beneath them is solid, monitored, and reinforced.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Building the "Defensive By Design Perimeter."
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fnq2o7qms7a6s0q6w07sj.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fnq2o7qms7a6s0q6w07sj.gif" alt="Penguin gif showing layering bricks to symbolise the building phase. I hope to make sense as I go on and it will slowly be clear why we need to architect systems starting from the backend to the front end" width="480" height="480"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Before a single user is able trigger any request through my new system, I needed to know that the system will protect its most expensive resource: &lt;strong&gt;External API Credits!&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;I am making use of what is called the &lt;strong&gt;Sensor &amp;amp; Suitcase&lt;/strong&gt; pattern. By implementing the &lt;strong&gt;Sensor &amp;amp; Suitcase&lt;/strong&gt; pattern, we shift the architecture from being &lt;strong&gt;reactive&lt;/strong&gt; (fixing things when users complain) to &lt;strong&gt;proactive&lt;/strong&gt; (handling failures before the user even notices).&lt;/p&gt;

&lt;p&gt;Here is why this "Backwards" approach is a game-changer for your system:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Total Visibility (The Sensors):&lt;/strong&gt; Most developers fly blind until an error log hits their inbox. By using ActiveSupport::Notifications to broadcast every “heartbeat”, you create a "Black Box" recorder for your app. Remember that a heartbeat is any user-defined activity and can be something like a user requesting a resource from my system. This way, you aren't just guessing how the system performs; the system is telling you exactly where it feels strain in real-time.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;# The following snippets of code and all that follow are  
# intentionally simplified to communicate architectural  
# intent rather than production-ready implementations.
# Implementation of the 'Sensor' within the service layer
def fetch_resource(query, location)
  # Wrapping the core logic in a 'Sensor' block
  ActiveSupport::Notifications.instrument('&amp;lt;Module name&amp;gt;::external_call', {
    query: query,
    location: location
  }) do |payload|
    result = &amp;lt;Module name&amp;gt;::Client.execute(query, location)

    # Enrich the sensor data with metadata for real-time telemetry
    payload[:status] = result.status
    payload[:duration_ms] = result.duration

    result
  end
end
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;2. De-risking Through Isolation (The Suitcase):&lt;/strong&gt; External APIs are often the "chaos variables" of web development. By packing those risks into SolidQueue background jobs, you decouple your app’s success from an outsider's failure. If a third-party service hangs for 10 seconds, your user doesn't see a spinning loading icon; they see a finished task because your "Suitcase" is handling the heavy lifting in the background.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;# The 'Suitcase' - Isolating external volatility from the main request thread
class &amp;lt;Module name&amp;gt;::IngestJob &amp;lt; ApplicationJob
  queue_as :external_api

  # Defensive retries with exponential backoff to handle provider downtime
  retry_on &amp;lt;Module name&amp;gt;::ExternalError, wait: :exponentially_longer, attempts: 3

  def perform(signal_id)
    signal = &amp;lt;Module name&amp;gt;::Signal.find(signal_id)

    # Perform the 'heavy lifting' away from the user's eyes
    &amp;lt;Module name&amp;gt;::Ingestor.new(signal).call
  rescue StandardError =&amp;gt; e
    # Log the failure for the 'Black Box' recorder
    &amp;lt;Module name&amp;gt;::Telemetry.record_failure(e)
    raise e
  end
end
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;3. The "Snappiness" Factor:&lt;/strong&gt; In a Rails 8 monolith, speed is often about what you don't do during the request-response cycle. This pattern strips the main thread down to its bare essentials, ensuring your UI stays lightning-fast while the complex logic executes safely behind the scenes.&lt;/p&gt;

&lt;p&gt;The Core Benefit here is that you aren't just building a feature; you are building a self-healing perimeter. Your system defends its own performance metrics, regardless of how the outside world (or the user) behaves.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Solving the "Thundering Herd" Problem
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F1m9s1v81ru26xzahxt6o.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F1m9s1v81ru26xzahxt6o.gif" alt="Gif showing an actual herd running. I hope to make sense as I go on and it will slowly be clear why we need to architect systems starting from the backend to the front end" width="480" height="266"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;One of the most satisfying parts of this "Backwards" approach was preparing for a "Thundering Herd" before it ever happened. This refers to the moment when multiple requests hit your system at once for the same resource.&lt;/p&gt;

&lt;p&gt;Usually, this is where most systems start to show off their glamorously covered-up cracks. In a situation where 10 users request the same data at the exact same millisecond, a standard app might fire off 10 expensive, redundant calls to an external API.&lt;/p&gt;

&lt;p&gt;However, by focusing on the "Self-Defence" layer first, I solved this before a single user ever signed up on my system. I used &lt;strong&gt;atomic increments in Redis&lt;/strong&gt; and &lt;strong&gt;PostgreSQL row locking&lt;/strong&gt; to make the system smarter. Now, when those 10 requests hit at the same time, the system identifies the "herd" and performs one expensive API call, while the other nine requests simply wait for a heartbeat to share that single result.&lt;/p&gt;

&lt;p&gt;Instead of 10 separate stresses on the engine, it’s just one. This is the difference between a panicked crowd and an organised queue.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;# Utilizing PostgreSQL Advisory Locks to organize the 'Herd'
def find_or_create_resource(query, location)
  # Create a unique lock key based on the specific search parameters
  lock_key = Digest::SHA256.hexdigest("#{query}:#{location}")

  # Ensure only ONE process handles the expensive external call
  &amp;lt;Module name&amp;gt;::Database.with_advisory_lock(lock_key) do
    # Re-check cache inside the lock; the first worker filled it already
    return cached_result if cached_result.present?

    # This is the single 'Tortoise' worker performing the expensive API trip
    execute_expensive_external_call(query, location)
  end
end
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  4. The Admin-First Dashboard
&lt;/h2&gt;

&lt;p&gt;Because I haven't spent a single minute working on a "User Profile" page, I had the time to build something much more valuable: a &lt;strong&gt;Telemetry Dashboard.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Right now, this dashboard isn't for a customer; it’s for the system (and, okay, it's for me to geek out on until I go live! xD). Instead of looking at user avatars or bios, I am looking at the "System’s Efficiency." I’ve built a metric I call the &lt;strong&gt;Vault Ratio&lt;/strong&gt;. This metric tracks how many requests are served from the "Vault" (my local, optimized data) versus how many require a slow, expensive trip to an external API.&lt;/p&gt;

&lt;p&gt;By seeing these numbers move in real-time, I can tune the engine while it’s still on the workbench. I’m not guessing if my caching strategy works; I’m watching the "Vault" grow stronger every day. By the time the first user signs up, they aren't entering a construction site; they are stepping into a fully furnished house.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;# The core logic behind the Telemetry Dashboard
def today_snapshot
  date = Date.current
  {
    discovery_requests:  &amp;lt;Module name&amp;gt;::Metrics.read('discovery.requests', date),
    cache_hits:          &amp;lt;Module name&amp;gt;::Metrics.read('discovery.cache_hits', date), # Local Rails cache
    vault_hits:          &amp;lt;Module name&amp;gt;::Metrics.read('discovery.vault_hits', date), # Persistent optimized data
    external_calls:      &amp;lt;Module name&amp;gt;::Metrics.read('discovery.external_calls', date), # Expensive credit usage
    signals_created:     &amp;lt;Module name&amp;gt;::Metrics.read('signals.created', date)
  }
end
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  The Lesson I have learned so far: Embrace the trenches
&lt;/h2&gt;

&lt;p&gt;In the past, I was always afraid of staying in the backend trenches for too long. There is a constant pressure to "show progress" through UI and buttons. But if there is one thing I’ve learned from systems that I didn’t architect well in the past, it’s this: &lt;strong&gt;Don’t be afraid to stay in the back-end longer than it feels comfortable.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Remember the story of the tortoise and the hare. The slow, intentional pace you take now is what prevents the dreaded 3:00 AM headaches down the line.&lt;/p&gt;

&lt;p&gt;When you build an engine that is self-defending and observable from day one, adding the "User" front-facing layer isn't going to be a stressful launch; it will be just a normal Tuesday. This approach gives you the confidence to sleep soundly, whether your system is handling 10 users or 10,000 users concurrently.&lt;/p&gt;

&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;As a parting shot and a last word of advice from an engineer implementing best software practices that help global businesses thrive, to build software that helps businesses truly thrive, we have to prioritize the "brain" over the "face." Focus on your system’s core logic and bulletproof it first. Make it exist, make it resilient, and then make it pretty.&lt;/p&gt;

&lt;p&gt;The users might not see the Redis locks or the background workers, but they will certainly feel the stability.&lt;/p&gt;

&lt;p&gt;Is the "Sensor &amp;amp; Suitcase" pattern something you or your team would consider for your next project? I’d love to hear how you handle the "Defensive" side of your builds in the comments!&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fcxq3fdu6jrufc7x7z3jn.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fcxq3fdu6jrufc7x7z3jn.gif" alt="Architecture Backwards: Engineering a Self-Defending System Before the UI Arrives" width="480" height="270"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A Quick Side Note:&lt;/strong&gt; While I’m heads-down building, I’m also looking to pitch in on new projects. If you’re looking for a mid-level Rails engineer with solid JavaScript experience who cares about building defensive, scalable systems, I’d love to chat. You can find me on [&lt;a href="https://www.linkedin.com/in/nemwel-nyandoro/" rel="noopener noreferrer"&gt;LinkedIn&lt;/a&gt;/&lt;a href="https://x.com/nemwel_bonie" rel="noopener noreferrer"&gt;Twitter&lt;/a&gt;] or check out my other work &lt;a href="https://github.com/Nemwel-Boniface" rel="noopener noreferrer"&gt;here&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;I hope this was useful information for you. See you in my next article.&lt;/p&gt;

</description>
      <category>rails</category>
      <category>architecture</category>
      <category>observability</category>
      <category>postgres</category>
    </item>
    <item>
      <title>Building a Custom Audit Trail in Ruby on Rails Without PaperTrail</title>
      <dc:creator>Nemwel Boniface</dc:creator>
      <pubDate>Thu, 21 Aug 2025 11:14:21 +0000</pubDate>
      <link>https://dev.to/nemwelboniface/building-a-custom-audit-trail-in-ruby-on-rails-without-papertrail-klk</link>
      <guid>https://dev.to/nemwelboniface/building-a-custom-audit-trail-in-ruby-on-rails-without-papertrail-klk</guid>
      <description>&lt;p&gt;&lt;strong&gt;Introduction&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Auditability in a system is a key feature, and having a way in which you can do that in your application will definitely help a lot. In your system, you need to know who did what, who created what, updated what, deleted what, and when this was done. Please note that for deletes, it is not a hard delete but a “soft” delete option whereby you have a boolean field maybe called “Archived,” which is set to false by default and set to true when you press to delete it (Archive it).&lt;/p&gt;

&lt;p&gt;While I know there exist gems that can do this for you, such as the &lt;a href="https://github.com/paper-trail-gem/paper_trail" rel="noopener noreferrer"&gt;papertrail&lt;/a&gt; gem, you may want to reduce the number of dependencies in your application by coming up with your own custom maker checker. This involves adding fields in your database table, such as created_by, deleted_by, modified_by, etc. Look, hear me out, this may sound counterintuitive to add a created_by field, but the aim of making a maker checker is to add all the needed fields.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Prerequisites&lt;/strong&gt;&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Ruby v 3+&lt;/li&gt;
&lt;li&gt;Rails V 7/8&lt;/li&gt;
&lt;li&gt;Rails app with Devise for Authentication&lt;/li&gt;
&lt;li&gt;Some experience with Rails MVC&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;strong&gt;Let's get started:&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fx08nu3bbsshx74su0l9h.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fx08nu3bbsshx74su0l9h.gif" alt="Cat getting started to build a custom audit system" width="480" height="360"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Assume that you have a table called modules. To add the auditable fields, run the following migration to create a migration file that we will update below:&lt;/p&gt;

&lt;p&gt;&lt;code&gt;rails g migration AddAuditFieldsToModules&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;Update your migration file to look like the one shown below:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;class AddAuditFieldsToModules &amp;lt; ActiveRecord::Migration[7.2]
  def change
    change_table :modules do |t|
      # Audit fields
      t.references :created_by, type: :uuid, foreign_key: { to_table: :users }
      t.references :modified_by, type: :uuid, foreign_key: { to_table: :users }
      t.references :deleted_by, type: :uuid, foreign_key: { to_table: :users }
      t.datetime :deleted_on

      # Archive status
      t.boolean :archive_status, default: false, null: false

      # Indexes
      t.index :archive_status
      t.index :deleted_on
    end
  end
end
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Run &lt;code&gt;rails db:migrate&lt;/code&gt; to update your Schema file.&lt;/p&gt;

&lt;p&gt;The next step would be to create a Current model inside &lt;code&gt;app/models/current.rb&lt;/code&gt;, which inherits from &lt;code&gt;ActiveSupport::CurrentAttributes&lt;/code&gt; and update it as follows:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;class Current &amp;lt; ActiveSupport::CurrentAttributes
  attribute :user
end
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The Current class file is needed to provide a thread-safe way to access the current user (or other request-specific attributes) throughout your application, particularly in models where you normally can't access controller-level information like the current_user devise helper method. By using ActiveSupport::CurrentAttributes, it creates a globally accessible but request-specific storage that your Auditable concern can use to automatically track who created, modified, or deleted (In our case, a delete is actually an archive) records. This maintains clean separation of concerns while ensuring your audit trail always captures the correct user, whether the action originates from controllers, background jobs, or other parts of your application. The Current pattern is the Rails-recommended way to handle this cross-cutting concern without polluting your models with controller logic.&lt;/p&gt;

&lt;p&gt;Our next step will be creating an auditable concern module inside &lt;code&gt;app/models/concerns/auditable.rb&lt;/code&gt; file and add the following code to it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;module Auditable
  extend ActiveSupport::Concern

  included do
    # Associations
    belongs_to :created_by, class_name: 'User', optional: true
    belongs_to :modified_by, class_name: 'User', optional: true
    belongs_to :deleted_by, class_name: 'User', optional: true

    # Scopes
    scope :active, -&amp;gt; { where(archive_status: false) }
    scope :archived, -&amp;gt; { where(archive_status: true) }

    # Callbacks
    before_create :set_created_by
    before_save :set_modified_by
  end

  # Instance method for archiving
  def archive!
    update(
      archive_status: true,
      deleted_by: Current.user,
      deleted_on: Time.current,
      modified_by: Current.user
    )
  end

  # Instance method for unarchiving
  def unarchive!
    update(archive_status: false, modified_by: Current.user)
  end

  private

  def set_created_by
    self.created_by ||= Current.user
    self.modified_by ||= Current.user
  end

  def set_modified_by
    return unless !new_record? &amp;amp;&amp;amp; Current.user
    self.modified_by = Current.user
  end
end
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Ok, ok, I know that this may seem like a lot, but we are mostly done now, you can relax.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Feln2kljcdgerj1xsh9r5.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Feln2kljcdgerj1xsh9r5.gif" alt="Cat getting started to build a custom audit system" width="500" height="357"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;Auditable&lt;/code&gt; concern provides automatic tracking of record authorship and changes through a complete audit trail system. It adds three user associations (created_by, modified_by, deleted_by) to any model that includes it, along with scopes for filtering active/archived records. Using Rails callbacks, it automatically stamps records with the current user (via Current.user) during creation, updates, and soft deletion (archiving). The concern also provides explicit archive! and unarchive! methods that handle soft deletion while maintaining referential integrity and a complete change history. This creates a self-contained audit system that tracks the complete lifecycle of records while keeping the implementation DRY and consistent across different models. The soft delete functionality preserves data by setting an archive_status flag rather than physically removing records.&lt;/p&gt;

&lt;p&gt;In case you may still need a way to perform a hard delete, you can extend the functionality by adding this to your &lt;code&gt;Auditable&lt;/code&gt; concern. Please note that this may be counterintuitive, as the record will no longer be there. So, setting the "deleted_on" or "deleted_by" will not be there as the record will be gone.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;# Callbacks
    before_destroy :set_destroyed_by

  def set_destroyed_by
    self.deleted_on = Time.current
    self.deleted_by = Current.user
    self.archive_status = true # Archive when destroyed
  end
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Inside our &lt;code&gt;module.rb&lt;/code&gt; model file, we shall add the following to first include the auditable module, including all its functionality, adding it into our module model class, and using the scope to create a query shortcut that automatically filters for non-archived records.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;# Auditability concern
  include Auditable
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Our setup is mostly complete, and you do not need to make any changes to your controller logic and The only place you need to make changes is in your destroy action, as shown below, which instead of performing an actual hard delete, calls the archive method, which we defined in our auditable concern earlier:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;def destroy
    @module.archive! # Soft delete / archive
    respond_to do |format|
      format.html { redirect_to modules_url, notice: "The Module '#{@module.name}' was successfully deleted." }
      format.json { head :no_content }
    end
  end 
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;So, why are we re-inventing the wheel?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Feyjxx4psugkcn4sakupt.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Feyjxx4psugkcn4sakupt.gif" alt="Cat getting started to build a custom audit system" width="500" height="500"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;So, why would someone want to use this approach as opposed to using pre-existing gems such as &lt;a href="https://github.com/paper-trail-gem/paper_trail" rel="noopener noreferrer"&gt;PaperTrail&lt;/a&gt;?&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Eliminating dependencies caused by using gems. While some gems are very useful, some may stop receiving updates, which may mean, further down the line, you may be forced to migrate to a custom auditability form or move to another gem&lt;/li&gt;
&lt;li&gt;It is in line with the native Rails way of doing things; you probably don't need those gems if you can implement it custom. Such a custom approach is generally faster than using &lt;a href="https://github.com/paper-trail-gem/paper_trail" rel="noopener noreferrer"&gt;PaperTrail&lt;/a&gt; by upto 2-3 times, reducing the amount of “magic” happening in your code.&lt;/li&gt;
&lt;li&gt;There is complete transparency of what is going on, what is being collected, and how everything works. This is unlike when using a gem, and there is some “magic’ that happens behind the scenes, and you do not fully understand how it works in the first place.&lt;/li&gt;
&lt;li&gt;To have control over the data you want to collect/ track in your system. While some gems are very useful for a quick fix for data collection, some may be designed to collect an excessive amount of data, which may not be needed and may definitely increase the database costs. Customization allows you to collect the least amount of data, only the ones that you need, and nothing more.&lt;/li&gt;
&lt;li&gt;This approach incorporates query efficiency in situations where you would like to know who did what; you just get it in the table columns instead of doing complex queries, making your application data load faster.&lt;/li&gt;
&lt;li&gt;For scenarios where you are using cloud storage, this will help you to avoid using cloud offerings such as AuditLog, which charges per event. This in place saves on costs for the company.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;strong&gt;When it is better to use existing solutions:&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F8168a6llheallhv17605.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F8168a6llheallhv17605.gif" alt="Cat getting started to build a custom audit system" width="480" height="268"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;While the pros of a custom approach are undeniable, there exist scenarios where you may need to use pre-existing gems as opposed to a custom solution like ours:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;It may be a regulatory Requirement for the company. For example, healthcare systems bound by HIPAA may require complete object versioning with cryptographic signing of all changes.&lt;/li&gt;
&lt;li&gt;In situations where you need to perform cross-model Search in your system. This occurs when you need to search audit trails globally.&lt;/li&gt;
&lt;li&gt;In a situation where you need a Full Change History in your system. Gems such as PaperTrail have reify for undo functionality&lt;/li&gt;
&lt;li&gt;When a separation of concerns is needed. This is where the production database and the audit database are in two separate databases, keeping everything neat and maintainable.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;strong&gt;Areas for Improvement (What Could Be Better)&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;While this approach is clean and avoids extra dependencies, there are several considerations to keep in mind if you want to make it production-ready:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Sometimes your system performs certain tasks, such as background jobs, where in such scenarios, there usually isn’t a current_user value, and it will be nil, which would make auditing hard. In such situations, assigning such changes to a dedicated System user account when no explicit user is available would be a safe workaround. For example:
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;def set_created_by
    # Assign the system user if no current user is present
    user = Current.user || User.find_by(email: 'system@yourdomain.com')
    self.created_by ||= user
    self.modified_by ||= user
  end
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ol&gt;
&lt;li&gt;When working with Rails APIs and you are exposing some of the audit fields, such as created_by or modified_by,  directly through JSON APIs, you may unintentionally leak personal information (e.g., user IDs, emails). From a compliance standpoint, you may need to consider whether those fields should be public, anonymized, or restricted to admin roles.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;strong&gt;Alternatives &amp;amp; Extensibility&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Just as Rails has some best practices for using the framework effectively, below are my best practices rules, which I highly recommend considering when choosing to have a custom audit mechanism for your application, may it be a custom audit tooling or a ready-made software:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;It is best to focus on capturing the changes, and not just actors: While I am aware that my current approach only records who changed a record, but not what they changed, I would recommend extending the functionalities with a changes_log JSON column or an associated audit_logs table that stores the changes made.&lt;/li&gt;
&lt;li&gt;Embracing database-level auditing: databases such as PostgreSQL offer native auditing solutions using triggers or extensions such as &lt;a href="https://www.pgaudit.org/" rel="noopener noreferrer"&gt;pg_audit&lt;/a&gt;, which are battle-tested solutions that can help you to log all operations at the database layer.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://www.geeksforgeeks.org/software-engineering/kiss-principle-in-software-development/" rel="noopener noreferrer"&gt;KISS&lt;/a&gt; (Keep it simple, stupid) and choose a hybrid approach of just recording “who/when” directly in your model (just as in my demonstrated examples), combined with a dedicated audit log table or external log system for mission-critical changes.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Auditing may feel daunting at first. However, knowing what amount of data you want to collect is the first step. Don’t sweat it. &lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Conclusion&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The choice ultimately depends on your application’s specific requirements. However, using the custom approach as I have demonstrated is an excellent fit for most mid-sized applications, prioritizing maintainability and performance.&lt;/p&gt;

&lt;p&gt;If you need a lightweight, Rails-native auditing functionality, this approach works perfectly well. However, if you need full versioning, support for cryptographic trails, or cross-model searching capabilities, it would be better to stick with PaperTrail or database-level solutions.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fcxq3fdu6jrufc7x7z3jn.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fcxq3fdu6jrufc7x7z3jn.gif" alt="This is the end of the Creating custom audit logs in Ruby on Rails" width="480" height="270"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A Quick Side Note:&lt;/strong&gt; While I’m heads-down building, I’m also looking to pitch in on new projects. If you’re looking for a mid-level Rails engineer with solid JavaScript experience who cares about building defensive, scalable systems, I’d love to chat. You can find me on [&lt;a href="https://www.linkedin.com/in/nemwel-nyandoro/" rel="noopener noreferrer"&gt;LinkedIn&lt;/a&gt;/&lt;a href="https://x.com/nemwel_bonie" rel="noopener noreferrer"&gt;Twitter&lt;/a&gt;] or check out my other work &lt;a href="https://github.com/Nemwel-Boniface" rel="noopener noreferrer"&gt;here&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;I hope this was useful information for you. See you in my next article.&lt;/p&gt;

</description>
      <category>ruby</category>
      <category>rails</category>
    </item>
    <item>
      <title>How to Automate Daily Task Emails in Rails using Whenever and Cron</title>
      <dc:creator>Nemwel Boniface</dc:creator>
      <pubDate>Wed, 16 Jul 2025 06:49:51 +0000</pubDate>
      <link>https://dev.to/nemwelboniface/how-to-automate-daily-task-emails-in-rails-using-whenever-and-cron-724</link>
      <guid>https://dev.to/nemwelboniface/how-to-automate-daily-task-emails-in-rails-using-whenever-and-cron-724</guid>
      <description>&lt;p&gt;&lt;strong&gt;Background&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;I have been working on a task assignment system where one of the tasks I need to do is to send everyone at the end of the work day stats of what tasks they had at the start of the work day and what tasks they have been able to close and if there are any new tasks that they were assigned during that work day. Initially, I created a manual method to send emails to them by using a button that I had to click at the end of each workday. This approach worked perfectly well. However, in situations where I was not there physically to click the button to send out the daily tasks reports to all users, this quickly became an issue for me, and coming up with a way to automate this process is no longer an option, but mandatory. This is where the Ruby Whenever gem comes in.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fdc99f9zvhxzizq8ns2eo.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fdc99f9zvhxzizq8ns2eo.gif" alt="Finding solution for automating email sending with the Whenever Gem" width="480" height="270"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Introduction: Whenever Gem&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Whenever Gem is a Ruby DSL(domain-specific language) for managing Unix-style cron jobs. It translates your Ruby syntax into actual crontab entries that your system can use and understand. As a result, it works reliably on Unix-based systems (Linux, macOS). Still, it is not compatible with Windows unless you use a Unix-like environment (such as WSL) or adapt your jobs to Windows Task Scheduler.&lt;/p&gt;

&lt;p&gt;The Gem provides a clear syntax for writing and deploying cron jobs. A cron job is a time-based job schedule that allows users to automate tasks by scheduling commands or scripts to run at specific times/ intervals. The scheduled tasks are known as cron jobs and are all defined in a configuration file known as the crontab. A cron is a daemon that runs in the background and checks the crontab file for scheduled tasks to run. Remember that a daemon is a computer program that runs in the background, performing tasks without direct user interaction.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What are schedulers?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Schedulers are components that trigger certain tasks at specific intervals (hourly, daily, weekly, etc). Schedulers operate in the background, outside of the request/ response lifecycle (You do not run them when a user visits your website).&lt;/p&gt;

&lt;p&gt;While Rails does not have a native (inbuilt) scheduler for arbitrary cron-style jobs, we have options for using Background jobs using ActiveJobs + Sidekiq. Or asynchronous work and making use of external scheduler tools (such as whenever + sidekiq-scheduler) for repeating jobs.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Prerequisites:&lt;/strong&gt;&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Rails V7&lt;/li&gt;
&lt;li&gt;PostgreSQL database&lt;/li&gt;
&lt;li&gt;Ruby V 3+&lt;/li&gt;
&lt;li&gt;Preferred web browser &lt;/li&gt;
&lt;li&gt;Visual studio code&lt;/li&gt;
&lt;li&gt;Some experience with Rails MVC architecture&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;strong&gt;Installing the Whenever Gem&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fx08nu3bbsshx74su0l9h.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fx08nu3bbsshx74su0l9h.gif" alt="Cat setting up Whenever Gem in the project" width="480" height="360"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Installing the &lt;a href="https://github.com/javan/whenever" rel="noopener noreferrer"&gt;whenever Gem&lt;/a&gt; and configuring it to work is fairly straightforward in Ruby on Rails. To install the whenever gem, add the following to your GEMFILE and run &lt;code&gt;bundle install&lt;/code&gt; to install the package to your application.: &lt;/p&gt;

&lt;p&gt;&lt;code&gt;gem 'whenever', require: false&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;Optionally run the following in your terminal:&lt;/p&gt;

&lt;p&gt;&lt;code&gt;bundle add whenever&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;Then run the command &lt;code&gt;wheneverize .&lt;/code&gt; to create the &lt;code&gt;schedule.rb&lt;/code&gt; file inside the &lt;code&gt;config/&lt;/code&gt; directory. This step is very crucial as the &lt;code&gt;schedule.rb&lt;/code&gt; file is where you define your cron jobs using Ruby DSL syntax (e.g., every :day, at: '5:05pm'). Without this file, the &lt;a href="https://github.com/javan/whenever" rel="noopener noreferrer"&gt;whenever gem&lt;/a&gt; has no instructions to generate crontab entries.&lt;/p&gt;

&lt;p&gt;If you would like to follow along with me through the article, I created &lt;a href="https://github.com/Nemwel-Boniface/scheduler_app" rel="noopener noreferrer"&gt;this&lt;/a&gt; GitHub repository which you can clone to test out the setup.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Real world practice: Sending out an email every weekday at 5:05 pm&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fr7nq6t5sxuznktjscfjp.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fr7nq6t5sxuznktjscfjp.gif" alt="Getting started with a Real world setup of Whenever Gem in the project" width="500" height="500"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;This is a relatable situation where you may want to automate the process of sending out emails every workday at 5:05 pm. To do this, we will first create a mailer. In your terminal, head over and type:&lt;/p&gt;

&lt;p&gt;&lt;code&gt;rails g mailer DailyMailer&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;This generates a file in the &lt;code&gt;app/mailers/&lt;/code&gt; directory called &lt;code&gt;daily_mailer.rb&lt;/code&gt;, which we are going to open so that we can edit it by adding the following to it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Class DailyMailer &amp;lt; ApplicationMailer
  def daily_email
    mail (to: "user@gmail.com", subject: "My update"
  end
end
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The next step will be to create a view inside the &lt;code&gt;app/views/daily_mailer/&lt;/code&gt; directory called &lt;code&gt;daily_email.html.erb&lt;/code&gt; adding this line: &lt;code&gt;&amp;lt;h1&amp;gt;Hello! This is your daily update at 5:05 pm &amp;lt;/h1&amp;gt;&lt;/code&gt; to it.&lt;/p&gt;

&lt;p&gt;We shall then create a Rake task inside the &lt;code&gt;lib/tasks/&lt;/code&gt; creating the file called &lt;code&gt;email.rake&lt;/code&gt;, using the command &lt;code&gt;touch lib/tasks/email.rake&lt;/code&gt;, and open it to edit it as follows:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;namespace :email do
  desc "Your daily email"
  task send_daily_email: :environment do:
    DailyMailer.daily-email.deliver_now
  end
end
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;We shall now run the command: &lt;code&gt;rails email:send_daily_email&lt;/code&gt;, to test out our current setup.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How the Automation Flow Works&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Before we test locally with Letter Opener, let’s visualize the full workflow:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fl9f8binbryyzsm1phngz.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fl9f8binbryyzsm1phngz.png" alt="Flowchart showing the flow from generating the cron task to when the email is sent" width="800" height="173"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Key Steps showcased in the flowchart include:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;schedule.rb&lt;/strong&gt; which defines the cron schedule in Ruby (e.g., "Weekdays at 5:05 PM").&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;crontab&lt;/strong&gt; system converts this into a background process.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Rake Task&lt;/strong&gt; which executes your custom logic (e.g., querying tasks).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Mailer&lt;/strong&gt; which formats and sends the email.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Letter Opener (Dev) / SMTP (Prod)&lt;/strong&gt; which handles delivery of your email.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;strong&gt;Integrating the email send automation with the Whenever Gem&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;We are now going to ensure that after every work day at 5:05 pm, users can receive an email sent to them. Open the &lt;code&gt;config/schedule.rb&lt;/code&gt; file and inside it add:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;every :week, at: '5:05 pm', on: [:monday, :tuesday, :wednesday, :thursday, :friday] do
  rake "email:send_daily_email"
end
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;To be able to check the output, run the command &lt;code&gt;whenever&lt;/code&gt; in your terminal. In case nothing happens on your end, not to worry. It means that you do not have any SMTP server configured, and this is where we will resolve it in the next section of the article, the letter_opener gem.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Local testing using the letter_opener gem&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;In case you do not have an &lt;a href="https://aws.amazon.com/what-is/smtp/" rel="noopener noreferrer"&gt;email SMTP&lt;/a&gt; setup locally, we will use the &lt;a href="https://github.com/javan/whenever" rel="noopener noreferrer"&gt;letter_opener&lt;/a&gt; Gem that catches any email that we want to send. Instead of trying to connect to an SMTP server, it renders the email in our browser and is an amazing tool for testing, as it will show your email like an HTML page for you. This means that:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;We do not have to configure an email server&lt;/li&gt;
&lt;li&gt;We can see and test emails quickly&lt;/li&gt;
&lt;li&gt;We do not send any actual emails over the internet&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If you are following along upto here, right now when we try to send the email by connecting to &lt;code&gt;localhost:25&lt;/code&gt;, it fails because we do not have an SMTP server configured locally. But with the letter opener gem configured, when our app tries to send an email using the &lt;code&gt;mail()&lt;/code&gt; or &lt;code&gt;deliver_now()&lt;/code&gt;, letter opener intercepts it and saves it as an HTML page. Then it automatically opens the page in your browser for you. The letter opener gem inline allows you to test your mailer easily, with no error messages, no ECONNREFUSED, and you can review your email layout. Amazing right?&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How to use the letter opener gem&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;You will add the &lt;code&gt;gem 'letter_opener'&lt;/code&gt; to your Gemfile in the &lt;strong&gt;development group&lt;/strong&gt;, and run bundle install. Open the &lt;code&gt;config/environments/development.rb&lt;/code&gt; file and edit it to add the following configurations:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;config.action_mailer.perform_deliveries = true
config.action_mailer.delivery_method = :letter_opener
# Don't care if the mailer can't send.
config.action_mailer.raise_delivery_errors = false

# Disable caching for Action Mailer templates even if Action Controller
# caching is enabled.
config.action_mailer.perform_caching = false

config.action_mailer.default_url_options = { host: "localhost", port: 3000 }
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;These settings configure Action Mailer to actually send emails in development mode, preview them in the browser using the letter_opener Gem, suppress delivery errors, disable caching for email templates, and generate proper URLs using localhost:3000 as the base. Amazing right?&lt;/p&gt;

&lt;p&gt;Once this has been done, run: &lt;code&gt;rails email:send_daily_email&lt;/code&gt; in your terminal. If you have followed along so far, your default email browser will be opened, and you will see something similar to what I have below.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F27c6jztyepwjg4s3q0el.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F27c6jztyepwjg4s3q0el.png" alt="HTML output when you run rails email:send_daily_email" width="800" height="384"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Up to this point, it will load the email in the browser as expected. The application will no longer attempt to connect to &lt;code&gt;localhost:25&lt;/code&gt; (SMTP port) and will instead open your browser.&lt;/p&gt;

&lt;p&gt;Up to where we are, we have been able to test out our setup, and we all validated that it works perfectly fine.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F0u5bzcku8r62o4ct7hb1.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F0u5bzcku8r62o4ct7hb1.gif" alt="We have been able to solve the problem and we can now comfortably test our setup locally" width="480" height="480"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Notes about the letter_opener gem:&lt;/strong&gt;&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Is not for production, it is a tool for development testing alone&lt;/li&gt;
&lt;li&gt;Helps you test and view emails quickly without sending them explicitly over an SMTP network&lt;/li&gt;
&lt;li&gt;Fixes the connection refused error for local testing&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;strong&gt;Considerations while using the whenever and the letter_opener gems&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;When you update the schedule.rb file, and you run the command &lt;code&gt;whenever&lt;/code&gt;, Rails will automatically assume &lt;code&gt;RAILS_ENV = production&lt;/code&gt; by default. So, when you run &lt;code&gt;whenever&lt;/code&gt; without specifying any flags, it shows you the schedule tasks for production and not for development. This may mean that you are not going to see any output and you may think that your setup is all wrong. Luckily, we have a few work a rounds for this and below are some options to use when testing out in development mode. See attached screenshot below as an example.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fp70bzga8d6powulss7mk.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fp70bzga8d6powulss7mk.png" alt="Running Whenever in production mode will just not work" width="798" height="128"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Remember that running the &lt;code&gt;whenever&lt;/code&gt; command is essential because it converts your human-readable schedule written in Ruby (in schedule.rb) into raw &lt;a href="https://www.liquidweb.com/help-docs/what-is-a-cron/" rel="noopener noreferrer"&gt;cron&lt;/a&gt; syntax that your system understands. Without this step, your defined jobs won’t actually get scheduled (We mentioned that whenever doesn't run jobs itself. It simply tells your operating system when and how to run them by updating the &lt;a href="https://www.techtarget.com/searchdatacenter/definition/crontab" rel="noopener noreferrer"&gt;crontab&lt;/a&gt;). If you skip this step, your background jobs will never run, no matter how well your Rake tasks or mailers are written.&lt;/p&gt;

&lt;p&gt;To fix this, we have a few options, starting with the temporary option, which is running the command shown below which will allow you to preview the schedule for development environment.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;whenever --set environment=development&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;The other solution will be:&lt;/p&gt;

&lt;p&gt;&lt;code&gt;whenever --update-crontab --set environment=development&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;To explicitly set the environment in the schedule.rb file at the top of the schedule.rb file, you will add the following:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;set :environment, "development"
set :output, "log/cron.log"
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;After adding this to our &lt;code&gt;schedule.rb&lt;/code&gt; file, we will need to run the command &lt;code&gt;whenever --update-crontab&lt;/code&gt; With the flag &lt;code&gt;--update-crontab&lt;/code&gt; because running &lt;code&gt;whenever&lt;/code&gt; alone simply previews what the generated cron entries would look like but it does not install them. To actually schedule your tasks, you must run &lt;code&gt;whenever --update-crontab&lt;/code&gt;, which writes those entries into your system's active crontab.&lt;/p&gt;

&lt;p&gt;To confirm whether this was successful run &lt;code&gt;crontab -l&lt;/code&gt; in your terminal. If you were s, you should see something like:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Figszjulhcnjial06zeii.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Figszjulhcnjial06zeii.png" alt="Successful write cron entries" width="800" height="186"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Always remember that:&lt;/strong&gt; the whenever Gem does not send emails itself, it just tells your OS (using cron) to run the command at a specific time. In our case, we want it to run every time at 5:05 pm every workday. The cron service will run &lt;code&gt;RAILS_ENV=development bundle exec  rake email:send_daily_email&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fflcn6n35bnczxyq23zgz.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fflcn6n35bnczxyq23zgz.gif" alt="We are so cool for automating sending of emails" width="500" height="387"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Conclusion&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Technology exists to help us work faster and focus on what truly matters. Let it handle repetitive, automatable tasks like sending daily emails at specific times so you can focus on building smarter systems.&lt;/p&gt;

&lt;p&gt;By using the &lt;code&gt;whenever&lt;/code&gt; gem together with &lt;code&gt;letter_opener&lt;/code&gt;, we’ve implemented a powerful and practical scheduling pattern. With this setup, we’ve removed human dependency from a critical workflow and learned how to safely test scheduled jobs in a development environment without the need for a live email server.&lt;/p&gt;

&lt;p&gt;This one marks the end of the step by step guide on automating tasks using Whenever Gem and Cron. What will you be automating next in your systems? I would love to hear about that. &lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fcxq3fdu6jrufc7x7z3jn.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fcxq3fdu6jrufc7x7z3jn.gif" alt="This is the end of the automate tasks using Whenever Gem and Cron" width="480" height="270"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;I hope this was useful information for you. See you in my next article.&lt;/p&gt;

</description>
      <category>automation</category>
      <category>rails</category>
      <category>ruby</category>
    </item>
    <item>
      <title>Stimulus.js Explained: A Beginner’s Guide for Ruby on Rails Developers</title>
      <dc:creator>Nemwel Boniface</dc:creator>
      <pubDate>Mon, 10 Feb 2025 07:26:10 +0000</pubDate>
      <link>https://dev.to/nemwelboniface/stimulusjs-explained-a-beginners-guide-for-ruby-on-rails-developers-45mb</link>
      <guid>https://dev.to/nemwelboniface/stimulusjs-explained-a-beginners-guide-for-ruby-on-rails-developers-45mb</guid>
      <description>&lt;p&gt;&lt;strong&gt;Introduction&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Hello friends!&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fru2iii2laoal0wbz6hn3.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fru2iii2laoal0wbz6hn3.gif" alt="Welcome to the first part of the Stimulus.js Explained: A Beginner’s Guide for Ruby on Rails Developers" width="498" height="362"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;We all have to agree on one thing, JavaScript is the king of the web. It is very true that it is the undisputed language that powers over 98.7% of websites, making it the dominant language for web interactivity. You can get so far with plain HTML and CSS when working on your websites. You will however want to have some reactivity on your website. Imagine without JavaScript in your website, you may not be able to implement features such as changing content on the DOM.&lt;/p&gt;

&lt;p&gt;As Ruby on Rails developers, we often prefer server-side rendering for speed and SEO benefits and at all costs, we try to reduce the reliance of JavaScript on our applications and rely on server-side rendering of content and sending it to our views to be shown to the client side. This has multiple advantages as this approach allows for pages to load quickly and be indexed by search engines without requiring JavaScript to render content. This is however still limiting and thus need for JavaScript to add that pop to our application reactivity.&lt;/p&gt;

&lt;p&gt;This is where Stimulus.js comes in. It is a lightweight JavaScript framework that adds interactivity to HTML designed to enhance static or server-rendered HTML by connecting JavaScript objects to elements on the page using simple annotations. Let this not be confused with Turbo, Turbo focuses on enhancing client-side interactivity by handling page updates, while Stimulus is more focused on managing client-side behavior and adding interactivity to HTML elements.&lt;/p&gt;

&lt;p&gt;This article will give you a general overview of Stimulus, covering its features, explain why Stimulus is useful, especially in Ruby on Rails, and give a comparison with other libraries such as React or Jquery. In a future article, we will cover how to set up and use Stimulus in your Ruby on Rails applications.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Stimulus JS deep dive&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fyjuc661xb4fddoe2tias.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fyjuc661xb4fddoe2tias.gif" alt="It is time to get ouselves in the mud as we learn about Stimulus JS" width="480" height="360"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;We earlier mentioned about JavaScript objects. In Stimulus, these JavaScript objects are called controllers. Stimulus uses &lt;code&gt;data-controller&lt;/code&gt; attributes to continuously monitor the pages. For each attribute, Stimulus looks at the attribute’s value to find a corresponding controller class, creates a new instance of that class, and connects it to the element. I would like you to think of it this way: just like the &lt;code&gt;class attribute&lt;/code&gt; is a bridge connecting HTML to CSS, Stimulus’s &lt;code&gt;data-controller&lt;/code&gt; attribute is a bridge connecting HTML to JavaScript.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Stimulus JS core concepts&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F7rb2snyyzhlb0c5c5rlg.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F7rb2snyyzhlb0c5c5rlg.gif" alt="We have a few more concepts to cover about Stimulus JS" width="480" height="362"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Stimulus JS builds on controllers with three key concepts: actions, targets, and values. Let’s discuss the further:&lt;/p&gt;

&lt;p&gt;1.&lt;strong&gt;Actions&lt;/strong&gt;, which connect controller methods to DOM events using &lt;code&gt;data-action&lt;/code&gt; attributes. Actions allow you to specify what action to take when eg a button is clicked. As an example, assume that you have a controller called &lt;code&gt;hello_controller&lt;/code&gt; that has a method called &lt;code&gt;greet&lt;/code&gt;, and you would like to connect that &lt;code&gt;greet&lt;/code&gt; method to an HTML button, your action would look similar to this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;&amp;lt;button data-action="click-&amp;gt;hello#greet"&amp;gt; Greet &amp;lt;/button&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;where the data action is a click event and that maps it to the &lt;code&gt;greet&lt;/code&gt; method in the hello controller.&lt;/p&gt;

&lt;p&gt;2.&lt;strong&gt;Targets&lt;/strong&gt;, which locate elements of significance within a controller. Assuming that you have an input field and you would like when you click the button, the value that is in the input field will be captured and that value will be used somewhere else, this is where the &lt;code&gt;data-&amp;lt;controllerName&amp;gt;-target&lt;/code&gt; attribute comes in. For example, we have our input field, we may want to reference this value as follows:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;// Our views
&amp;lt;div data-controller="greet"&amp;gt;
  &amp;lt;input type="text" data-greet-target="name" placeholder="Enter your name"&amp;gt;
  &amp;lt;button data-action="click-&amp;gt;greet#sayHello"&amp;gt;Say Hello&amp;lt;/button&amp;gt;
  &amp;lt;p data-greet-target="output"&amp;gt;&amp;lt;/p&amp;gt;
&amp;lt;/div&amp;gt;

// Hello controller
import { Controller } from "@hotwired/stimulus";

export default class extends Controller {
  static targets = ["name", "output"];

  sayHello() {
    this.outputTarget.textContent = `Hello, ${this.nameTarget.value}!`;
  }
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In the code shown above, the Stimulus controller &lt;code&gt;"hello"&lt;/code&gt; defines the &lt;code&gt;nameTarget&lt;/code&gt; and the &lt;code&gt;outputTarget&lt;/code&gt; to reference the HTML input and paragraph. It then updates the paragraph with a greeting message when the button is clicked.&lt;/p&gt;

&lt;p&gt;3.&lt;strong&gt;Values&lt;/strong&gt;, allow you to store and update dynamic data within a Stimulus controller using the &lt;code&gt;data-*&lt;/code&gt; attributes. They automatically sync between the HTML and JavaScript, making it easy to manage dynamic data inside Stimulus controllers without manually handling DOM updates. A good example is using values to create a counter that increments or decrements when a button is clicked.&lt;/p&gt;

&lt;p&gt;Phew! That was almost a lot of technical content on Stimulus JS, right? Ok, let's take it easier now.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fdg8z1odyx1m4s3mylwpk.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fdg8z1odyx1m4s3mylwpk.gif" alt="Phew! That was almost a lot of technical content right?" width="480" height="360"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why anyone should be using Stimulus.js in their applications&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fqixzaf5w0d6vj7ye6r48.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fqixzaf5w0d6vj7ye6r48.gif" alt="I know you are already asking yourself why you need Stimulus JS in your applications" width="480" height="200"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;We have so far seen what Stimulus is, and had an understanding of its core features. But why should you want to use it anyway? Let’s explore more below:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;Can help you with basic interaction enhancement. Take an example of when you want to implement a toggle button functionality in your website.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Form input handling. A great example is the sample I shared earlier when covering the stimulus js concepts (Actions, targets, values).&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Fast and lightweight. As compared to similar libraries such as React, it is lightweight and minimal making sure that your application does not experience any lags due to heavy packages.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;It has a seamless integration with Ruby on rails. Stimulus is designed to easily work with Ruby on Rails, especially alongside Turbo, and does not affect the existing backend structure.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Has automatic element identification - uses data-targets to reference elements within a controller, reducing the need for manual &lt;code&gt;document.querySelector&lt;/code&gt; calls and keeping JavaScript neatly organized.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Stimulus allows for progressive enhancement of your web applications' client views. It works on top of server-rendered pages, thus ensuring that the core functionality remains intact even if JavaScript is disabled, This improves accessibility and SEO of the web pages.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Despite using Stimulus.js showing real tangible benefits to our applications, they also have some serious considerations that are worth factoring in when using Stimulus over other libraries such as React or Vue.&lt;/p&gt;

&lt;p&gt;Stimulus may not be well suited for applications that are very complex and need single page (SPA) functionalities. If your app requires complex state management, virtual DOM updates, or client-side routing, you may need a framework like React or Vue.&lt;/p&gt;

&lt;p&gt;The fact that Stimulus is tightly coupled with HTML and relies heavily on the &lt;code&gt;data-*&lt;/code&gt; attributes in HTML, leads to having the code become cluttered if not managed properly. This is especially true in large applications.&lt;/p&gt;

&lt;p&gt;While this might not be necessarily true for everyone, however, the syntax that Stimulus uses is not really intuitive at first glance and requires additional reading to try and have a grasp of the syntax to understand the moving parts. This extra layer of complexity might not really suit everyone and they may find it hard to start working on it quickly.&lt;/p&gt;

&lt;p&gt;One of the determining factors of the use of a tool is usually the community behind it. Unlike other libraries such as React, Stimulus has a relatively smaller community backing it which would bring a challenge in finding resources or even help when you run into a challenge. You however need not worry because I am here to bridge that gap! 😄&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Conclusion&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Just like any tool out there, it has its strengths and its weaknesses. In my own opinion, Stimulus is an amazing tool that I believe everybody should be using especially in their Rails applications to add some reactivity to their pages. Stimulus features such as reusable components make it an attractive tool allowing you to define a feature once and reuse it multiple times allowing you to stick to programming principles such as DRY and KISS. &lt;/p&gt;

&lt;p&gt;In my next article, we shall go deeper into the technical bits covering a practical demonstration of how to use Stimulus JS in your Ruby on Rails applications.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fy806dk56b318m3m1gs2m.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fy806dk56b318m3m1gs2m.gif" alt="I will be back with the part two of this technical article series" width="480" height="309"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;That wraps up this introduction to Stimulus. I hope you found it insightful, and I look forward to sharing a hands-on guide in the next article.&lt;/p&gt;

</description>
      <category>rails</category>
      <category>stimulus</category>
      <category>ruby</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Mastering Code Reviews: The Ultimate How-To Series</title>
      <dc:creator>Nemwel Boniface</dc:creator>
      <pubDate>Mon, 28 Aug 2023 10:43:29 +0000</pubDate>
      <link>https://dev.to/nemwelboniface/mastering-code-reviews-the-ultimate-how-to-series-11e1</link>
      <guid>https://dev.to/nemwelboniface/mastering-code-reviews-the-ultimate-how-to-series-11e1</guid>
      <description>&lt;p&gt;&lt;strong&gt;Introduction&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Hello friends!&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fru2iii2laoal0wbz6hn3.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fru2iii2laoal0wbz6hn3.gif" alt="Welcome to the first part of the Mastering Code Reviews: The Ultimate How-To Series" width="498" height="362"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Picture this, assigned a certain amount of features to implement. how will you know that the code submitted by them is functioning according to the given project requirements?  You check it! This is where the code review process kicks in.&lt;/p&gt;

&lt;p&gt;This will be a multiple series where I aim to teach you all you need to know about code reviewing. In this article, I will teach you what we mean by code review, why code review is important, what are the qualities of a good code review, what makes up a good code reviewer, list down some drawbacks associated with the code review process, and wrap it up with a conclusion. In a later article, I will do a hands-on tutorial on how to make use of the GitHub review features available to perform a code review.&lt;/p&gt;

&lt;p&gt;At the end of the series, you will be more equipped to start making reviews for your peers and add code reviewing as a skill under your belt.&lt;/p&gt;

&lt;p&gt;Sounds interesting right? Let's dive straight in!&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What do we mean by Code review?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Code review in software engineering means the systematic, collaborative, and iterative process in which one or more developers examine code written by team members scanning for errors in the code to assess whether that submitted code does exactly what it is supposed to do as per the provided project requirements.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fu9osfm6p0pusndlor50c.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fu9osfm6p0pusndlor50c.gif" alt="Wondering what we mean by the code review process" width="300" height="300"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Code review is collaborative since it involves at least two people, the author of the code and another person who reviews the code. It is systematic since to perform a meaningful code review, there needs to be a set standard followed to approach the code review process. It is also iterative since if there are errors found, the code will be sent back for revisions and when done put back into review. This process is repeated until the code satisfies the provided project requirements.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why is the code review process important?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Code review is one of the most important steps in the software development lifecycle. So what are the advantages that you will accrue from doing code reviews?&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;For your junior developers in your team, code review facilitates the transfer of knowledge and helps them to learn faster. This is because as they collaboratively receive feedback on their code from their peers or the seniors, they can learn new ways of doing something or why implementing features differently is better.&lt;/li&gt;
&lt;li&gt;It serves as a bug detection avenue that might have been missed by the written tests for the project. This is because the code reviewer will generally be someone who is knowledgeable and has some understanding of the future implications of not re-writing a code block and rectifying it during the code review process.&lt;/li&gt;
&lt;li&gt;We often have the "Make it work optimize later". Code review helps to eliminate code that is unreadable or unnecessarily complex making it unmaintainable. It is here where that complex code will be rewritten when you are provided with suggestions of how to better approach an issue. This will in turn make the code readerable and maintainable.&lt;/li&gt;
&lt;li&gt;Code review ensures consistency of the whole code base. This is because different organizations have different ways in which they structure their code. This is always enforced during the code review process where any code that is not up to the company's standard is returned to the author and changes are required.&lt;/li&gt;
&lt;li&gt;Code review enhances the interaction and collaboration of the different team members. This is because different people might be the ones to review your code and in this way, you get to interact with different people within the organization.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;strong&gt;What are the qualities of a good code review?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;We have to acknowledge that anyone can give a code review. However, we have to ask ourselves, was the review given of quality? Below are some of the pointers for a good code review:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;One that is provided promptly. It would not be good if you had to wait for a long time to receive a review of your code. Remember that time is of the essence when creating software and you would be required to also start working on another feature which requires that this feature be approved.&lt;/li&gt;
&lt;li&gt;One that is thorough and does not miss some of the important aspects. It would hurt if you received few changes to make in the first review and when you submit your code for a re-review, the code reviewer introduces new issues that were not mentioned in the first review.
One that is constructive and does not deviate from the project requirements. The reviewer needs to raise issues in the code that are actionable and not guessed issues.&lt;/li&gt;
&lt;li&gt;One that uses respective language and tone and acknowledges your efforts so far. We all make mistakes and the best you can do is to be respectful to someone when you are suggesting changes. This would make the one who is to make the changes respected and acknowledged and will make the changes with enthusiasm.&lt;/li&gt;
&lt;li&gt;One that offers a learning opportunity. This depends on the kind of changes that will be requested and the approach that will be used by the reviewer which will either help the junior developer learn by offering constructive feedback or break them by adding unrealistic changes to be made.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;strong&gt;What makes up a good code reviewer?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;We can all see why code reviews are very important and by now, you might be itching to get your hands dirty reviewing your friend's code. But wait! Do you have the qualities required to become a good code reviewer?  Let's see if you possess any of the below qualities. A good code reviewer is one who:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Has strong communication skills. Remember that when performing a code review, you will be explaining to someone, "This code is great, but it has some issues. Here is a suggested way to fix it, and here is why you should consider making this change".&lt;/li&gt;
&lt;li&gt;Has a strong technical understanding of the underlying technologies being used. You cannot make a code review for something that you do not understand and this is why to become a good code reviewer, you need to have mastery of the underlying technologies to be used.&lt;/li&gt;
&lt;li&gt;Is empathetic and patient. The truth is making changes to your code is hard. Most of the time, the author might not be able to implement exactly how it is supposed to be implemented and this is where being empathetic and patient comes into play. You will be better able to accommodate everyone even the least experienced developers in your team.&lt;/li&gt;
&lt;li&gt;Is one who can think critically. Making a code review involves assessing the code. It is up to you as the code reviewer to assess whether this submitted code is optimum or might require changes. This is where the skill of critical thinking comes into play.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;strong&gt;What are some drawbacks associated with the code review process?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;While it is evident that the code review process does have many advantages, it is not always bliss, and like everything, there are drawbacks related to performing code reviews. They include:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Sometimes the code review process can be very time-consuming. This is based on the fact that there could be multiple revisions which take time to both implement changes and re-review.&lt;/li&gt;
&lt;li&gt;The code review process could lead to conflicts between team members. When one of the team members feels that the other party is intentionally requesting changes even though the project works perfectly fine that could lead to a lot of conflicts between team members.&lt;/li&gt;
&lt;li&gt;Sometimes the code review process could be subjective and might not be objective. Remember that the code review process relies on what someone thinks about a specific code and might not be as objective as someone would want it to be.&lt;/li&gt;
&lt;li&gt;With the possibility of making numerous revisions of the code, this would significantly slow down the development lifecycle making it take longer to implement features than they should have taken. This has a direct effect on cost since the longer a project takes to be completed the higher the cost involved in its development.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;strong&gt;Conclusion.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;From my discussion above, we can see why code reviews are important. We also flipped the other side of the coin and saw the cons that come with the code review process. Would you be willing to do a code review for your peer or suggest it to your team members? Please let me know in the comments section bel&lt;/p&gt;

&lt;p&gt;This brings us to the end of the first part of this series. After reading this article, are you able to define what code review is, what are the advantages of performing code reviews, how you can distinguish whether the code review you received was good or not, and for those who are already excited about code reviews, the qualities that would help make them good code reviewers?&lt;/p&gt;

&lt;p&gt;Part B of this series will include a practical tutorial teaching you how to perform reviews making use of the GitHub review features. Stay tuned! &lt;/p&gt;

&lt;p&gt;If you read till this point, I send a big thank you.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fpipqyqtwsp4vunl5ysz1.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fpipqyqtwsp4vunl5ysz1.gif" alt="I thank my readers for reading this far" width="480" height="400"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;That is it for today's class. I hope this was useful information for you. See you in my next article.&lt;/p&gt;

</description>
    </item>
    <item>
      <title>Mastering Technical Writing: A Step-by-Step Guide for Beginners</title>
      <dc:creator>Nemwel Boniface</dc:creator>
      <pubDate>Sun, 18 Jun 2023 16:43:03 +0000</pubDate>
      <link>https://dev.to/nemwelboniface/mastering-technical-writing-a-step-by-step-guide-for-beginners-o7h</link>
      <guid>https://dev.to/nemwelboniface/mastering-technical-writing-a-step-by-step-guide-for-beginners-o7h</guid>
      <description>&lt;p&gt;&lt;strong&gt;Introduction&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Hello my esteemed reader! This article is well suited for beginners in technical writing but will still benefit seasonal writers who would like to take their writing skills to the next level. This will be a multiple series where I aim to take you from point A where you know nothing about technical writing to point Z where you write meaningful articles that people actually want to read.&lt;/p&gt;

&lt;p&gt;In this short article, I will explain what technical articles are, discuss the characteristics of a good article, extensively explore steps that you can take to write a good article, and conclude with a summary as I answer the question, "are technical articles limited to software related articles alone?".&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What do we mean by "Technical articles"?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Technical writing. A huge word that most people get wrong and only associate with software-related articles and publications. However, today I am here to demystify this misconception and explain to you my reader why technical writing is not restricted to software-related articles alone. &lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fiodvkt8axrnouqsegejo.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fiodvkt8axrnouqsegejo.gif" alt="Woman wondering what Technical writing means" width="480" height="360"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;For us to understand what this is, we need to have an understanding of what the two terms technical and writing mean. Technical means "relating to a particular subject, art, or craft, or its techniques." anywhere mentioning software? no. The term Writing encompasses the process of documenting ideas, thoughts, or facts. Therefore combining the two, technical writing is the process of conveying complex and specialized information clearly and concisely.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What are the indicators of a good article?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Have you ever read an article and something about it spelled out quality? Well, quality is one subjective term. However, in technical writing, we have standardized indicators of what a quality article is that I will list below:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;An article with a purpose and has a target audience. This is an article that clearly defines the scope of the specific article that is being written and has an identified audience beforehand.&lt;/li&gt;
&lt;li&gt;An article that combines the written text with visual aids such as images, gifs, and screenshots to further explain a specific concept shedding more light on it by using visuals to arouse more interest.&lt;/li&gt;
&lt;li&gt;An article that makes use of relatable examples and analogies. Research has it that when hard concepts are accompanied with an analogy that the readers can relate to, they end up remembering that concept by relating it to the analogy that was used to explain it.&lt;/li&gt;
&lt;li&gt;An article that is consistent throughout and has accurate information. Nothing hurts more than an article whose flow seems inconsistent and whose content is misleading.&lt;/li&gt;
&lt;li&gt;An article that has been proofread and is free of grammatical and spelling errors.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;strong&gt;What are the steps to follow when writing an article?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Anyone can write an article. However, not everyone is able to write a good article. Luckily enough with the right preparation, we can all be able to write resourceful artiles and this will be what will separate your article from the vast majority of sub standard articles published online following the steps highlighted below:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Preparation - they say, given 10 hours to cut down a tree, use 7 hours of that time sharpening your axe and 3 hours to cut it down. This is the step where you get to understand what you are going to write it and why you actually need to write it. It is in this stage where you choose the medium which you will use to relay your thoughts through your article.&lt;/li&gt;
&lt;li&gt;Research - before you even start to write about an article, you need to get an understanding of the topic at hand and do an assessment of whether this is something that you are capable of writing about. Here is where you get your hands dirty busy going deep that topic/ domain to get an understanding of it before writing about it.&lt;/li&gt;
&lt;li&gt;Organization - with extensive research, comes a monolith of data that will not make sense as information. You need to sit back, organize your thoughts and see what bits of all this raw data combined will lead to a meaningful information for your article.&lt;/li&gt;
&lt;li&gt;Once you have your thoughts organized, Writing the article is the next logical step. It is in this step where you jot down your thoughts either on paper or on an online editor that offers grammar and spelling checks such as Grammarly.&lt;/li&gt;
&lt;li&gt;The last and final step is to proofread your work. In this step I advice sitting on your article for a few days and asking a friend to proof check your work  and this will allow you to have a third eye/ opinion that will help you see if your article has a good thought/ logical flow.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;strong&gt;Conclusion&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;From the steps listed above, it is evident that technical articles are not restricted to software related articles alone. A technical article can be any article written for a specific domain, be it hospitality, engineering or medicine all qualify as technical articles. Therefore when someone says that other articles are not technical articles, please direct them to this article where they will also get enlightened.&lt;/p&gt;

&lt;p&gt;This brings us to the end of the first part of this series. After reading this article, are you able to recognize a well researched and written article? Please do let me know in the comment section. In the next article, I shall walk you through how you can develop an appropriate writing style in technical writing and how you can structure your article. &lt;/p&gt;

&lt;p&gt;If you read till this point, I send a big thank you.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fpipqyqtwsp4vunl5ysz1.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fpipqyqtwsp4vunl5ysz1.gif" alt="I thank my readers for reading this far" width="480" height="400"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;That is it for today's class. I hope this was useful information for you. See you in my next article.&lt;/p&gt;

</description>
    </item>
    <item>
      <title>Implementing Custom Slugs with FriendlyID in Rails: A Step-by-Step Technical Guide</title>
      <dc:creator>Nemwel Boniface</dc:creator>
      <pubDate>Mon, 29 May 2023 07:38:21 +0000</pubDate>
      <link>https://dev.to/nemwelboniface/implementing-custom-slugs-with-friendlyid-in-rails-a-step-by-step-technical-guide-4jme</link>
      <guid>https://dev.to/nemwelboniface/implementing-custom-slugs-with-friendlyid-in-rails-a-step-by-step-technical-guide-4jme</guid>
      <description>&lt;p&gt;&lt;strong&gt;Introduction:&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Have you ever wondered how applications like Instagram or Twitter use your username as the unique identifier in your browser URL and not the ID of your account?&lt;/p&gt;

&lt;p&gt;In the development of applications, the use of eg usernames instead of the ID is called slugging. In programming, slugs are the human readable unique identifying parts of a web address typically at the end of the URL (&lt;a href="https://developer.mozilla.org/en-US/docs/Learn/Common_questions/Web_mechanics/What_is_a_URL" rel="noopener noreferrer"&gt;Uniform Resource Locator&lt;/a&gt;).&lt;/p&gt;

&lt;p&gt;Using custom slugs for our applications offers many advantages that I will discuss later in the article. Implementing them in our applications is relatively easy and luckily for us who use Ruby on Rails, we get the advantages of using a mature stable framework where many functionalities have solutions that were already implemented for us.&lt;/p&gt;

&lt;p&gt;In Ruby on Rails, we have a gem called &lt;a href="https://github.com/norman/friendly_id" rel="noopener noreferrer"&gt;friendly_id&lt;/a&gt; that makes the process of generating human-readable slugs simple by allowing us to replace ids in our URLs with slugs. Using &lt;a href="https://github.com/norman/friendly_id" rel="noopener noreferrer"&gt;friendly_id&lt;/a&gt; gem lets our applications work with friendly URLs eg &lt;code&gt;https://www.my_app/users/nemwelboniface&lt;/code&gt; instead of &lt;code&gt;https://www.my_app/users/1&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Typically, the implementation of custom slugs in Rails involves leveraging gems like &lt;a href="https://github.com/norman/friendly_id" rel="noopener noreferrer"&gt;FriendlyID&lt;/a&gt; that streamline the process and offer additional functionalities like resolving slug conflicts and automatically generating easily readable slugs.&lt;/p&gt;

&lt;p&gt;Within this article, we shall delve into a comprehensive step-by-step walk through that demonstrates how to implement custom slugs in Rails with the assistance of the &lt;a href="https://github.com/norman/friendly_id" rel="noopener noreferrer"&gt;FriendlyID&lt;/a&gt; gem. My guide will encompass essential aspects such as installation, configuration, migration, and code generation, enabling a seamless integration of custom slugs into your Rails application. By the conclusion of this tutorial, you will possess the necessary knowledge and tools to craft personalized and user-friendly URLs tailored to meet your specific needs.&lt;/p&gt;

&lt;p&gt;Let's dive in and unlock the power of custom slugs in Rails!&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Prerequisites:&lt;/strong&gt;&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Rails V7&lt;/li&gt;
&lt;li&gt;PostgreSQL database&lt;/li&gt;
&lt;li&gt;Ruby V 3+&lt;/li&gt;
&lt;li&gt;Visual studio code&lt;/li&gt;
&lt;li&gt;Some experience with Rails MVC architecture.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;strong&gt;Project set-Up&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fx08nu3bbsshx74su0l9h.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fx08nu3bbsshx74su0l9h.gif" alt="Cat setting up friendly_id for custom slugs in the project" width="480" height="360"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;To set up the custom slugs functionality in our application, we need to add the &lt;a href="https://github.com/norman/friendly_id" rel="noopener noreferrer"&gt;friendly_id&lt;/a&gt; gem to our Gemfile and run bundle install.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;gem "friendly_id"
bundle install
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you would like to follow along, I created a new branch called &lt;code&gt;friendly_id&lt;/code&gt; in &lt;a href="https://github.com/Nemwel-Boniface/activestorage_tutorial" rel="noopener noreferrer"&gt;this repository&lt;/a&gt; where you can follow along with me. If you would like to know how to create a new Rails application, kindly have a look at the first part of &lt;a href="https://dev.to/nemwelboniface/from-theory-to-practice-using-active-storage-for-file-management-in-rails-4o3f"&gt;this article&lt;/a&gt; I wrote.&lt;/p&gt;

&lt;p&gt;Our next step will involve adding a slug column to our desired table. In our case, it will be the Users table. Therefore we shall run the following line in our terminal which will create a migration file that adds a slug column to our Users table.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;rails g migration AddSlugToUsers slug:uniq
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;We shall also need to generate the friendly configuration file and a new migration with the following command:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;rails generate friendly_id
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;We are now free to run the migrations to update the schema file with the new columns added by the migration files created.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;rails db:migrate
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;We are going to head over to our Users model inside app/models/user.rb file and add the following:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;class User &amp;lt; ApplicationRecord
  extend FriendlyId
  friendly_id :name, use: :slugged
end
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With the line for "extend FriendlyId" we are extending the functionalities of the User model with the methods that are provided by the FriendlyId module. This is what allows us to use the "friendly_id" method in the next line.&lt;/p&gt;

&lt;p&gt;With the line "friendly_id :name, use: :slugged" this is the line that configures the friendly_id gem for the user model. This is where we specify that the name attribute of the user model should be used as the basis for generating the slugs. The :slugged option indicates that the friendly_id gem should use the slugged module to generate the slug.&lt;/p&gt;

&lt;p&gt;Making use of the above means that when a new user record is being created, the friendly_id gem will generate a slug based on the name attribute. The slug is then stored inside the slug column in our user's table.&lt;/p&gt;

&lt;p&gt;In addition to the above, let us head back to our user's model inside app/models/user.rb file and create a to_param method. Our User model shall now be like this with all the changes made:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;class User &amp;lt; ApplicationRecord
  extend FriendlyId
  friendly_id :name, use: :slugged

  def to_param
    slug
  end
end
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This method is necessary when using the friendly_id gem to ensure that our slug is used in the URL instead of the default ID. What it does is overrides the default behavior of Rails when generating URL's for model instances.&lt;/p&gt;

&lt;p&gt;By defining the to_param method and returning the slug value, you are instructing Rails to use the slug attribute when generating the URL's for instances of our model.&lt;/p&gt;

&lt;p&gt;We are almost done now. We need to make some slight adjustments to our controllers. Head over to our users controller in app/controllers/users_controller.rb file and inside the show action and set_user method, replace this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;@user = User.find(params[:id])
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;with:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;@user = User.friendly.find(params[:id])
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;What is happening here is we are using the friendly_id gem to find a user's record based on its slug. The params[:id] value is passed in as the argument to the find method. Basically using the friendly.find, the user record will be found based on its slug attribute instead of the ID which is the default behavior of &lt;a href="https://guides.rubyonrails.org/active_record_basics.html" rel="noopener noreferrer"&gt;ActiveRecord&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;The last step involves running the following in our Rails console:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;User.find_each(&amp;amp;:save)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F1t5l00omfuuxhhpvl4wt.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F1t5l00omfuuxhhpvl4wt.gif" alt="Note to when you need to run the line above" width="480" height="480"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Note that running the above line is only necessary if you're adding friendly_id to an already existing Rails application and you need to generate slugs for already existing users.&lt;/p&gt;

&lt;p&gt;What that line does is it iterates over each user's record and calls the save method on them. This triggers the saving process and generation of slugs for each user based on the friendly_id configuration in our user model.&lt;/p&gt;

&lt;p&gt;This step is not necessary if you setup friendly_id before creating users in your application. If you find having to run the line in your console application tedius, you have an option of creating a &lt;a href="https://www.jetbrains.com/help/ruby/rake.html" rel="noopener noreferrer"&gt;rake task&lt;/a&gt; that can be used to automate that process for you. However, this is beyond the scope of what we were to learn in this article. I will however consider writing an article about rake tasks upon request from someone in the comments section.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Frjd42tsqhy1gpwdrwmx2.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Frjd42tsqhy1gpwdrwmx2.gif" alt="That's all you need to create custom slugs!" width="480" height="480"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Pros and cons of using custom slugs&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;We have covered the technical setup and I believe that so far you have an idea of why having custom slugs in your application is a good thing. Below I will list a few advantages that I have found from using custom slugs in my own applications:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;It has helped me to improve the SEO of my applications ten fold as search engines tend to give more weight to keywords in the URL when determining the relevance of a page.&lt;/li&gt;
&lt;li&gt;Using custom slugs gives you a human readable URL that gives more context and describes what thet specific webpage you are viewing contains. This rings true especially when creating blogs where you can have th title of a blog as the slug for a blogs show page.&lt;/li&gt;
&lt;li&gt;Using custom slugs has given my applications alot of flexibility as the slugs are more flexible and meaningful than numeric  ID's. Having keywords in the slugs has really helped users of my applications to identify and remember the contents of webpages that they visited from my application.&lt;/li&gt;
&lt;li&gt;Persistence and a sense of security in my application as unline numerical IDs are sequential and predictable by revealing th order in which records were created, having the custom slugs give me a sense of security as they remain consistent even if the record is moved or re-ordered in the database.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Despite using slugs showing real tangible benefits to our applications, they also have some serious considerations which are worth factoring when using custom slugs over the default ID's. You need to ensure that the slugs are unique, they can handle situations where the title or the attribute that is used for the slug is changed and also properly handling URL redirections when slugs are modified.&lt;/p&gt;

&lt;p&gt;Luckily for us, in Rails all the above concerns are already handled by the &lt;a href="https://github.com/norman/friendly_id" rel="noopener noreferrer"&gt;friendly_id&lt;/a&gt; gem where the process of handling these edge case is simplified. This is one of the many advantages we get from using a mature and stable framework like Ruby on Rails for development of our applications.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Conclusion&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Would you like to use friendly_id in your applications after reading my short article? Please let me know in the comments section below.&lt;/p&gt;

&lt;p&gt;This one marks the end of the Step-by-Step Guide: Implementing Custom Slugs with FriendlyId in Rails article guide.&lt;/p&gt;

&lt;p&gt;That is it for today's class. I hope this was useful information for you. See you in my next article.&lt;/p&gt;

</description>
    </item>
  </channel>
</rss>
