<?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: Juan Camilo Auriti</title>
    <description>The latest articles on DEV Community by Juan Camilo Auriti (@juanauriti).</description>
    <link>https://dev.to/juanauriti</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%2F3279930%2F4faa1f38-8049-4b7d-a93f-a217cf7eaefa.jpg</url>
      <title>DEV Community: Juan Camilo Auriti</title>
      <link>https://dev.to/juanauriti</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/juanauriti"/>
    <language>en</language>
    <item>
      <title>YouTube is the #1 cited domain in Google AI Overviews. Reddit is #2. Your blog is nowhere.</title>
      <dc:creator>Juan Camilo Auriti</dc:creator>
      <pubDate>Tue, 13 Oct 2026 07:00:00 +0000</pubDate>
      <link>https://dev.to/juanauriti/youtube-is-the-1-cited-domain-in-google-ai-overviews-reddit-is-2-your-blog-is-nowhere-5fi</link>
      <guid>https://dev.to/juanauriti/youtube-is-the-1-cited-domain-in-google-ai-overviews-reddit-is-2-your-blog-is-nowhere-5fi</guid>
      <description>&lt;p&gt;Ahrefs published &lt;a href="https://ahrefs.com/blog/most-cited-domains-ai-overviews/" rel="noopener noreferrer"&gt;the most comprehensive ranking of domains cited in Google AI Overviews&lt;/a&gt; in September 2026. They tracked every domain AI Overviews cited across over 3 million US queries using their Brand Radar tool, then ranked them by mention share.&lt;/p&gt;

&lt;p&gt;The top 10:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;#&lt;/th&gt;
&lt;th&gt;Domain&lt;/th&gt;
&lt;th&gt;Mention share&lt;/th&gt;
&lt;th&gt;Pages cited&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;youtube.com&lt;/td&gt;
&lt;td&gt;22.9%&lt;/td&gt;
&lt;td&gt;1,737,252&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;reddit.com&lt;/td&gt;
&lt;td&gt;18.5%&lt;/td&gt;
&lt;td&gt;830,641&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;facebook.com&lt;/td&gt;
&lt;td&gt;10.1%&lt;/td&gt;
&lt;td&gt;514,175&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;google.com&lt;/td&gt;
&lt;td&gt;8.8%&lt;/td&gt;
&lt;td&gt;1,220,228&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;td&gt;instagram.com&lt;/td&gt;
&lt;td&gt;5.6%&lt;/td&gt;
&lt;td&gt;277,245&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;6&lt;/td&gt;
&lt;td&gt;quora.com&lt;/td&gt;
&lt;td&gt;4.7%&lt;/td&gt;
&lt;td&gt;171,198&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;7&lt;/td&gt;
&lt;td&gt;en.wikipedia.org&lt;/td&gt;
&lt;td&gt;4.0%&lt;/td&gt;
&lt;td&gt;88,400&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;8&lt;/td&gt;
&lt;td&gt;tiktok.com&lt;/td&gt;
&lt;td&gt;3.3%&lt;/td&gt;
&lt;td&gt;140,750&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;9&lt;/td&gt;
&lt;td&gt;amazon.com&lt;/td&gt;
&lt;td&gt;3.3%&lt;/td&gt;
&lt;td&gt;148,116&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;10&lt;/td&gt;
&lt;td&gt;walmart.com&lt;/td&gt;
&lt;td&gt;1.0%&lt;/td&gt;
&lt;td&gt;47,425&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The top 5 domains account for 65.9% of all AI Overview citations. The top 10 account for 82.2%.&lt;/p&gt;

&lt;p&gt;Look at what is not on that list. No tech blog. No SEO publication. No developer documentation site. No content marketing hub. The domains that AI Overviews cite most are user-generated content platforms, social media, e-commerce, and Wikipedia.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why YouTube dominates
&lt;/h2&gt;

&lt;p&gt;YouTube is the single most-cited domain, with 22.9% of all citations and 1.7 million distinct pages cited. A separate Ahrefs analysis found that among AIO citations that did not rank in Google's top 100 for the same keyword, 18.2% were YouTube URLs. YouTube is getting cited even when it does not rank organically for the query.&lt;/p&gt;

&lt;p&gt;This makes sense when you understand how AI Overviews work. Google's documentation describes &lt;a href="https://developers.google.com/search/docs/appearance/ai-features" rel="noopener noreferrer"&gt;query fan-out&lt;/a&gt;: the model splits the original query into related sub-queries and retrieves pages for each. Video content surfaces for sub-queries where text results are thin. "How to" queries, product demonstrations, and visual tutorials trigger video results that traditional text content cannot serve.&lt;/p&gt;

&lt;p&gt;YouTube also has a structural advantage. Every video page has a structured description, timestamps, transcript data, and metadata that Google's own systems can parse cleanly. The content is already segmented into chapters. It is, in effect, pre-chunked by creators who do not think of themselves as doing content chunking.&lt;/p&gt;

&lt;h2&gt;
  
  
  Reddit as the second pillar
&lt;/h2&gt;

&lt;p&gt;Reddit captures 18.5% of citations with 830,641 pages cited. The WashU study (55,393 queries, presented at the October 2026 ACM Internet Measurement Conference) noted that AI Overviews rely less heavily on user-generated content than traditional first-page results, but Reddit's position in the Ahrefs data suggests UGC is still a massive citation source in absolute terms.&lt;/p&gt;

&lt;p&gt;Reddit's advantage is freshness and specificity. A Reddit thread about "best laptop for video editing under $1000" contains multiple first-hand opinions, specific model numbers, and trade-off discussions that a generic buyer's guide cannot match. Google's AI guide explicitly calls for "first-hand" content and "unique points of view." Reddit is the largest repository of first-hand experience on the internet.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this means if you produce written content
&lt;/h2&gt;

&lt;p&gt;The implication is not "stop writing and start making YouTube videos." It is that the competitive landscape for AI citations is different from the landscape for organic search.&lt;/p&gt;

&lt;p&gt;In organic search, you compete against other pages targeting the same keyword. In AI Overviews, you compete against every format that answers any related sub-query the fan-out process generates. A blog post about "how to set up Supabase auth" is not just competing with other blog posts. It is competing with a YouTube tutorial, a Reddit thread, and a Stack Overflow answer.&lt;/p&gt;

&lt;p&gt;The content that still gets cited from written sources tends to share specific properties:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Original data and analysis.&lt;/strong&gt; The WashU study found that AIO-cited domains score higher on credibility than average first-page results. Sites that publish original research, proprietary data, or expert analysis get cited because the AI cannot synthesize that information from commodity content. This is what Google means by "non-commodity content."&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;First-hand experience.&lt;/strong&gt; Google's guide says it explicitly: "a first-hand review provides a unique point of view." If you have used a tool, built a project, or debugged a specific problem, that experience is not replicable by AI. It gets cited.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Specificity over breadth.&lt;/strong&gt; The CXL study showed that 55% of AI Overview citations come from the first 30% of a page. Pages that front-load a specific, well-defined answer get cited. Pages that build up to a general conclusion do not.&lt;/p&gt;

&lt;h2&gt;
  
  
  The uncomfortable part
&lt;/h2&gt;

&lt;p&gt;If you are a content marketer, the data says your competition is not other content marketers. It is a 19-year-old posting a phone recording on TikTok, a Reddit user writing a 200-word comment at 2am, and a Wikipedia editor who has maintained the same article for a decade.&lt;/p&gt;

&lt;p&gt;These sources get cited because they are specific, first-hand, and non-commodity. They are not optimized. They are not structured for AI. They are just real.&lt;/p&gt;

&lt;p&gt;The GEO playbook that tells you to add llms.txt and Schema markup is optimizing the wrong layer. The layer that matters is whether your content contains something that cannot be generated by the AI doing the citing. If it can, the AI already has the answer and has no reason to cite you.&lt;/p&gt;

&lt;p&gt;If it cannot, you do not need llms.txt. You need to publish it.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>seo</category>
      <category>webdev</category>
      <category>content</category>
    </item>
    <item>
      <title>55% of AI Overview citations come from the first 30% of your page. Here's the data.</title>
      <dc:creator>Juan Camilo Auriti</dc:creator>
      <pubDate>Sun, 11 Oct 2026 07:00:00 +0000</pubDate>
      <link>https://dev.to/juanauriti/55-of-ai-overview-citations-come-from-the-first-30-of-your-page-heres-the-data-le7</link>
      <guid>https://dev.to/juanauriti/55-of-ai-overview-citations-come-from-the-first-30-of-your-page-heres-the-data-le7</guid>
      <description>&lt;p&gt;Most SEO advice tells you to write a compelling introduction, build up to your main point, and deliver the answer after you have set the context. Two independent citation studies say the opposite: if your answer is not in the first 30% of the page, there is a better than even chance it is not getting cited by AI search engines at all.&lt;/p&gt;

&lt;h2&gt;
  
  
  The CXL study: 100 AI Overview citations
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://cxl.com/blog/google-ai-overview-citation-sources/" rel="noopener noreferrer"&gt;CXL analyzed 100 Google AI Overview citations&lt;/a&gt; to see where on the source page the cited passage actually appeared. They mapped each citation to its position relative to the full page length.&lt;/p&gt;

&lt;p&gt;The results:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;55%&lt;/strong&gt; of cited passages appeared within the first 30% of the source page.&lt;/li&gt;
&lt;li&gt;The 10-20% zone alone produced more citations than any other segment.&lt;/li&gt;
&lt;li&gt;The top 20% of pages accounted for 48 of 100 citations.&lt;/li&gt;
&lt;li&gt;The bottom 40% of pages accounted for only 21.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The concentration is not subtle. If your core answer lives at the 50% mark because you built up to it with context and storytelling, it sits in a part of the page that produced fewer citations in this sample than the first 10%.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Kevin Indig dataset: 18,012 ChatGPT citations
&lt;/h2&gt;

&lt;p&gt;Kevin Indig ran &lt;a href="https://www.searchenginejournal.com/google-ai-overview-citations-from-top-ranking-pages-drop-sharply/568637/" rel="noopener noreferrer"&gt;a larger analysis&lt;/a&gt; across 1.2 million search results and 18,012 verified ChatGPT citations. The pattern was the same:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;44.2%&lt;/strong&gt; of ChatGPT citations came from the first 30% of the document.&lt;/li&gt;
&lt;li&gt;After the first third, citation likelihood drops sharply.&lt;/li&gt;
&lt;li&gt;Indig described it as a "ski ramp" effect: a steep cliff after the first 30%, then a long, slow tail.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Two different AI systems (Google AI Overviews and ChatGPT), two different datasets (100 citations and 18,012 citations), same shape. The retrieval step that feeds both systems appears to weight content position in a way that rewards front-loading.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this happens
&lt;/h2&gt;

&lt;p&gt;Google's own documentation explains the mechanism. AI Overviews use &lt;a href="https://developers.google.com/search/docs/appearance/ai-features" rel="noopener noreferrer"&gt;query fan-out&lt;/a&gt;: the model splits the original query into multiple related sub-queries, retrieves pages for each, and assembles a response from the results. The retrieval step has a context budget. When a page is fetched, the model reads from the top. If the answer is buried at 60%, it may not make it into the context window before the budget is spent.&lt;/p&gt;

&lt;p&gt;This is the same reason the CXL study found that FAQ sections can still get cited from deep page positions: each FAQ question-and-answer pair is structurally self-contained, so the model can extract a complete answer without needing the surrounding context. But for narrative content, position matters.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the Ahrefs data adds
&lt;/h2&gt;

&lt;p&gt;The Ahrefs analysis of 863,000 keywords and 4 million AI Overview URLs found that only &lt;strong&gt;38%&lt;/strong&gt; of cited pages appeared in the organic top 10 for the same query, down from 76% in July 2025. A separate BrightEdge analysis put the number at 17%.&lt;/p&gt;

&lt;p&gt;This means most cited pages do not rank for the exact keyword being searched. They rank for related sub-queries generated by fan-out. Your page does not need to hold the top position for the primary keyword to get cited. It needs to contain a clear answer to a related question, and that answer needs to be near the top of the page.&lt;/p&gt;

&lt;h2&gt;
  
  
  Practical changes
&lt;/h2&gt;

&lt;p&gt;If you write technical content, blog posts, or documentation, the implication is straightforward:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Put the answer first, context second.&lt;/strong&gt; If your article is about a specific problem, the clearest statement of the solution should appear in the first 20% of the page. Not after the third subheading, not after you have warmed the reader up.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Front-load definitions.&lt;/strong&gt; If your page defines a concept, the definition should be in the first paragraph or two. The CXL data shows the 10-20% zone is the single most productive segment for citations.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Keep FAQ sections for specific questions.&lt;/strong&gt; FAQ blocks can still get cited from deep positions because each answer is self-contained. This is the one structural pattern that survives the position effect.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Measure with Search Console.&lt;/strong&gt; Google introduced dedicated &lt;a href="https://developers.google.com/search/docs/fundamentals/ai-optimization-guide" rel="noopener noreferrer"&gt;Search Generative AI performance reports&lt;/a&gt; in June 2026, rolled out worldwide by August 31. You can now track AI Overview impressions directly rather than relying on third-party tools.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this does not mean
&lt;/h2&gt;

&lt;p&gt;Front-loading is not the same as dumbing down. The data says the answer should be early, not that the page should be short. The bottom 40% of pages still produced 21 of 100 citations in the CXL study. Context, examples, and depth still matter for readers who click through. But the AI retrieval step that decides whether you get cited appears to read from the top and stop when it has enough.&lt;/p&gt;

&lt;p&gt;The pages that win in AI search are the ones where the first thing a human reader finds is also the first thing a retrieval system extracts. If you have been writing introductions that delay the answer, the citation data says to stop.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>webdev</category>
      <category>content</category>
      <category>seo</category>
    </item>
    <item>
      <title>Google's mythbusting guide says stop obsessing over Schema. The data agrees.</title>
      <dc:creator>Juan Camilo Auriti</dc:creator>
      <pubDate>Sat, 10 Oct 2026 07:00:00 +0000</pubDate>
      <link>https://dev.to/juanauriti/googles-mythbusting-guide-says-stop-obsessing-over-schema-the-data-agrees-30k1</link>
      <guid>https://dev.to/juanauriti/googles-mythbusting-guide-says-stop-obsessing-over-schema-the-data-agrees-30k1</guid>
      <description>&lt;p&gt;When Google published its &lt;a href="https://developers.google.com/search/docs/fundamentals/ai-optimization-guide" rel="noopener noreferrer"&gt;official guide on optimizing for generative AI features in Search&lt;/a&gt; in May 2026, it included a mythbusting section. The kind of section that upsets people who sell the things it busts.&lt;/p&gt;

&lt;p&gt;Here is what Google explicitly lists under things you do not need:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;You don't need to create new machine readable files, AI text files, markup, or Markdown to appear in generative AI search.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That covers llms.txt. But the guide goes further. It says standard technical SEO is what counts for AI features, and that no special schema, no special markup, and no special files are required beyond what Google has always recommended.&lt;/p&gt;

&lt;p&gt;The GEO agency playbook, meanwhile, still leads with three items: add llms.txt, add more Schema.org markup, and implement "content chunking" for AI retrieval. Let me check each one against the data.&lt;/p&gt;

&lt;h2&gt;
  
  
  llms.txt: the file 97% of sites publish for nothing
&lt;/h2&gt;

&lt;p&gt;I covered this in detail in the &lt;a href="https://dev.to/juanauriti"&gt;first part of this series&lt;/a&gt;. The short version: Ahrefs analyzed 137,210 domains and found that 97% of llms.txt files received zero requests in May 2026. SE Ranking analyzed 300,000 domains and found no correlation between having the file and being cited by AI. Limy tracked 515 million AI bot events and counted 408 requests to llms.txt. Removing the variable from their machine learning model improved prediction accuracy.&lt;/p&gt;

&lt;p&gt;Google's John Mueller compared llms.txt to the keywords meta tag. The keywords meta tag is the canonical example of a self-declared signal that search engines learned to ignore because every site claimed to be the best one. The same logic applies here: a file where you describe your own site as important is not a signal any serious retrieval system would trust.&lt;/p&gt;

&lt;h2&gt;
  
  
  Schema.org: necessary, but not the lever GEO agencies sell
&lt;/h2&gt;

&lt;p&gt;Google's guide is careful here. It does not say Schema is useless. It says you do not need &lt;em&gt;additional&lt;/em&gt; or &lt;em&gt;special&lt;/em&gt; schema for AI features. Standard structured data that helps Google understand your content continues to matter. What does not matter is the layer of AI-specific Schema that some agencies add to their deliverables.&lt;/p&gt;

&lt;p&gt;The evidence backs this up. The WashU study presented at the October 2026 ACM Internet Measurement Conference analyzed 55,393 searches and 61,212 citations. They found that 29.8% of cited domains did not appear in the first-page results at all, which means Google's AI source selection uses a pool distinct from its ranking algorithm. If Schema were the primary lever for AI citation, you would expect the most-marked-up pages to dominate. They do not.&lt;/p&gt;

&lt;p&gt;What the WashU study did find is that AIO-cited domains received higher average credibility scores than traditional first-page results. Credibility, in this context, is not about Schema. It is about source quality, domain authority, and content substance. Google's own guide reinforces this: the most important factor is "valuable, non-commodity content" with a "unique point of view."&lt;/p&gt;

&lt;p&gt;The Ahrefs analysis of 863,000 keywords and 4 million AI Overview URLs found that only 38% of cited pages appeared in the organic top 10 for the same query, down from 76% in their July 2025 study. A separate BrightEdge analysis put the number at 17%. If Schema markup were the deciding factor, the overlap with top-ranking pages (which typically have the most Schema) would be higher, not lower.&lt;/p&gt;

&lt;h2&gt;
  
  
  Chunking: the tactic with no public evidence
&lt;/h2&gt;

&lt;p&gt;"Content chunking" for AI retrieval is the idea that you should break your content into self-contained semantic units so an LLM can ingest them individually. It sounds plausible. It has no public evidence behind it.&lt;/p&gt;

&lt;p&gt;I searched for peer-reviewed studies, large-scale analyses, or even practitioner log data showing that chunked content gets cited more frequently than unchunked content. I found none. What I found instead is the CXL 100-citation study, which showed that 55% of AI Overview citations come from the first 30% of a page. Kevin Indig's analysis of 18,012 verified ChatGPT citations found the same pattern: 44.2% from the first 30%, dropping sharply after.&lt;/p&gt;

&lt;p&gt;That is not chunking. That is front-loading. The data says put your answer near the top, not split your content into pieces. Google's guide does not mention chunking at all, which is consistent with the absence of evidence for it.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the data says actually works
&lt;/h2&gt;

&lt;p&gt;The peer-reviewed GEO paper by Aggarwal et al. (KDD 2024) is the closest thing this field has to a landmark study. They built a benchmark (GEO-Bench) and measured what happens to page visibility when you edit the page itself. The methods that worked:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Adding citations to credible sources within your content&lt;/li&gt;
&lt;li&gt;Adding statistics&lt;/li&gt;
&lt;li&gt;Adding quotations from authoritative sources&lt;/li&gt;
&lt;li&gt;Improving content density and specificity&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;These are content-level edits, not file-level or markup-level changes. Their benchmark showed visibility boosts of up to 40% in generative engine responses. That is an effect size. llms.txt, extra Schema, and chunking have no published effect size.&lt;/p&gt;

&lt;p&gt;Google's guide and the independent data converge on the same conclusion. The work that matters for AI search visibility is the work that has always mattered for search: make content that is original, specific, and useful. The tactics that agencies package as "GEO-specific" are either things you should already be doing (basic Schema, crawlability, semantic HTML) or things with no measured effect (llms.txt, chunking, AI-specific markup).&lt;/p&gt;

&lt;h2&gt;
  
  
  What this means for your audit
&lt;/h2&gt;

&lt;p&gt;I run &lt;a href="https://github.com/Auriti-Labs/geo-optimizer-skill" rel="noopener noreferrer"&gt;geo-optimizer-skill&lt;/a&gt;, and we deliberately do not score sites on llms.txt presence, Schema count, or chunking. We score on the signals that have evidence: crawlability, content density, semantic structure, and whether your pages render for bots that do not execute JavaScript.&lt;/p&gt;

&lt;p&gt;If your GEO audit leads with llms.txt and Schema density, you are optimizing the easy-to-instrument metrics, not the outcome metrics. The easy ones produce diffs and look like work. The outcome metric (does your content get cited by AI) requires an experiment, not a dashboard.&lt;/p&gt;

&lt;p&gt;That is the trap. The same trap as tuning alert thresholds instead of asking whether the alert should exist. The metric that is easy to measure is rarely the one that describes the result.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>webdev</category>
      <category>opensource</category>
      <category>seo</category>
    </item>
    <item>
      <title>Google said llms.txt does nothing. I checked what 137,210 domains actually got.</title>
      <dc:creator>Juan Camilo Auriti</dc:creator>
      <pubDate>Fri, 09 Oct 2026 07:00:00 +0000</pubDate>
      <link>https://dev.to/juanauriti/google-said-llmstxt-does-nothing-i-checked-what-137210-domains-actually-got-2dka</link>
      <guid>https://dev.to/juanauriti/google-said-llmstxt-does-nothing-i-checked-what-137210-domains-actually-got-2dka</guid>
      <description>&lt;p&gt;The llms.txt file was supposed to be the bridge between your website and AI search engines. A markdown file at your root, a curated list of your best pages, a clean map for models that struggle with bloated HTML. Add it, and ChatGPT, Perplexity, and Google AI Overviews would finally find you.&lt;/p&gt;

&lt;p&gt;I wanted to believe it. I built an open-source GEO audit tool that checks for llms.txt among its signals. Then the data came in, and it doesn't say what the checklist articles say.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Google said
&lt;/h2&gt;

&lt;p&gt;In May 2026, Google published its &lt;a href="https://developers.google.com/search/docs/fundamentals/ai-optimization-guide" rel="noopener noreferrer"&gt;official guide on optimizing for generative AI features in Search&lt;/a&gt;. The guide has a mythbusting section. This is what it says about llms.txt and similar files:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;You don't need to create new machine readable files, AI text files, markup, or Markdown to appear in generative AI search.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Google's John Mueller had been saying it for over a year before it landed in the documentation. In April 2025, &lt;a href="https://www.searchenginejournal.com/google-says-llms-txt-comparable-to-keywords-meta-tag/544804/" rel="noopener noreferrer"&gt;answering a question on Reddit&lt;/a&gt;, he put it plainly:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"AFAIK none of the AI services have said they're using LLMs.TXT (and you can tell when you look at your server logs that they don't even check for it). To me, it's comparable to the keywords meta tag — this is what a site-owner claims their site is about … (Is the site really like that? well, you can check it. At that point, why not just check the site directly?)"&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The keywords meta tag. The canonical example of a signal search engines learned to ignore because every site claimed to be the best one.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Google did next
&lt;/h2&gt;

&lt;p&gt;Ten days before the Search guide told everyone to stop bothering, Google's Chrome team shipped an &lt;a href="https://developer.chrome.com/docs/lighthouse/agentic-browsing/llms-txt" rel="noopener noreferrer"&gt;llms.txt check inside Lighthouse&lt;/a&gt; as part of its experimental Agentic Browsing audits. The documentation, last updated May 5, 2026, says:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Without this file, agents may spend more time crawling the site to understand its high-level structure and primary content.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Two teams inside the same company, pointing in opposite directions. When pressed on the contradiction, Mueller framed it as: llms.txt is "not done for search." He called it a "temporary crutch, perhaps to save some tokens" for AI coding tools parsing developer documentation. Not something non-developer sites need.&lt;/p&gt;

&lt;p&gt;So which is it? I went to the data.&lt;/p&gt;

&lt;h2&gt;
  
  
  Study 1: Ahrefs, 137,210 domains
&lt;/h2&gt;

&lt;p&gt;Ahrefs published &lt;a href="https://ahrefs.com/blog/llmstxt-study/" rel="noopener noreferrer"&gt;the largest access study to date&lt;/a&gt; on June 15, 2026. They looked at every domain in Ahrefs Web Analytics that received traffic in May 2026 and checked each root for an llms.txt file returning HTTP 200.&lt;/p&gt;

&lt;p&gt;The numbers:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;28%&lt;/strong&gt; of the 137,210 domains publish an llms.txt file (about 38,000 sites).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;97%&lt;/strong&gt; of those files received zero traffic in May 2026. Nothing fetched them at all. Not AI bots, not generic crawlers, not humans.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;96%&lt;/strong&gt; of the requests that did reach llms.txt files came from bots.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;19.5%&lt;/strong&gt; of those bot fetches came from named AI tools. GPTBot was first, Claude-Code second. Both are training crawlers and coding agents, not answer engines.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Zero&lt;/strong&gt; requests came from AI bots for llms.txt files that didn't exist. AI bots never go looking.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That last point is the one that kills the mental model where a model "checks for" your file before answering a question about you. It doesn't. It fetches whatever URLs its retrieval step already surfaced, which is normally your HTML.&lt;/p&gt;

&lt;p&gt;Ahrefs also found something unsettling: the largest single research crawler in the dataset identified itself as &lt;code&gt;prompt-injection-survey/1.0&lt;/code&gt;. Someone is systematically studying llms.txt as a prompt injection vector, because AI agents are designed to ingest and trust whatever the file says.&lt;/p&gt;

&lt;h2&gt;
  
  
  Study 2: SE Ranking, 300,000 domains
&lt;/h2&gt;

&lt;p&gt;SE Ranking ran &lt;a href="https://seranking.com/blog/llms-txt/" rel="noopener noreferrer"&gt;a separate analysis&lt;/a&gt; of nearly 300,000 domains. They tested whether having an llms.txt file correlates with how often a domain gets cited by AI systems.&lt;/p&gt;

&lt;p&gt;It doesn't.&lt;/p&gt;

&lt;p&gt;They used both statistical correlation (Spearman) and machine learning (XGBoost with SHAP analysis). The result: removing the llms.txt variable from the model &lt;strong&gt;improved&lt;/strong&gt; its prediction accuracy. The file wasn't a weak signal. It was noise.&lt;/p&gt;

&lt;p&gt;Adoption was 10.13% across their dataset, roughly 1 in 10 sites. No major AI platform has publicly committed to reading it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Study 3: OtterlyAI, 90-day server log experiment
&lt;/h2&gt;

&lt;p&gt;OtterlyAI ran &lt;a href="https://otterly.ai/blog/the-llms-txt-experiment/" rel="noopener noreferrer"&gt;a controlled experiment&lt;/a&gt; over 90 days. They placed a correctly implemented llms.txt file at the root of a test website and monitored AI bot traffic.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;62,100+&lt;/strong&gt; total AI bot visits to the site over 90 days.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;84&lt;/strong&gt; of those visits targeted /llms.txt.&lt;/li&gt;
&lt;li&gt;That's &lt;strong&gt;0.1%&lt;/strong&gt; of AI bot traffic.&lt;/li&gt;
&lt;li&gt;The site's average content page received ~265 AI bot visits in the same window. The dedicated AI file performed 3x worse than a random page.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;They also noted that llms.txt "hardly performed better than an average .pdf file." AI crawlers don't treat it as privileged content.&lt;/p&gt;

&lt;p&gt;OtterlyAI subsequently removed the llms.txt checker from their GEO audit tool, because the data showed its impact on AI crawler behavior is "marginal at best."&lt;/p&gt;

&lt;h2&gt;
  
  
  Study 4: Limy, 515 million bot events
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://limy.ai/blog/llms-txt-in-2026-the-full-guide" rel="noopener noreferrer"&gt;Limy's analysis&lt;/a&gt; is the largest dataset I've seen on this question. They monitored 515,382,577 AI bot traffic events over a 90-day window across the brands they track.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;408&lt;/strong&gt; requests targeted /llms.txt directly.&lt;/li&gt;
&lt;li&gt;Out of 515 million.&lt;/li&gt;
&lt;li&gt;That's 0.00008% of total AI crawler traffic.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Their word for the share was "statistically negligible." The bots that matter for AI search visibility (GPTBot, ClaudeBot, PerplexityBot, OAI-SearchBot, Google-Extended) overwhelmingly skip the file and crawl HTML directly.&lt;/p&gt;

&lt;h2&gt;
  
  
  What actually works (according to the same studies)
&lt;/h2&gt;

&lt;p&gt;The studies don't say "do nothing." They say "do something different."&lt;/p&gt;

&lt;p&gt;Google's own guide is clear about what matters: create valuable, non-commodity content. If an AI can write your article on its own, it will never cite you. It already has the answer. The guide points to first-hand experience, unique data, and original analysis as the signals that make content worth surfacing.&lt;/p&gt;

&lt;p&gt;The Ahrefs study points out that the bots fetching llms.txt are coding agents reading developer documentation. If your site is API docs or SDK reference, a clean llms.txt genuinely helps. If your site is a blog, an ecommerce store, or a landing page, no agent is parsing your pages for fun. The file points at an empty room.&lt;/p&gt;

&lt;p&gt;What moves the needle, according to the peer-reviewed GEO paper by Aggarwal et al. (KDD 2024), is content-level edits: adding citations to credible sources, adding statistics, adding quotations. Their benchmark showed these methods can boost visibility in generative engine responses by up to 40%. That is an effect size. llms.txt has no measured effect size.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I did with my own tool
&lt;/h2&gt;

&lt;p&gt;I maintain &lt;a href="https://github.com/Auriti-Labs/geo-optimizer-skill" rel="noopener noreferrer"&gt;geo-optimizer-skill&lt;/a&gt;, an open-source Answer Engine Optimization audit tool with over 1,000 stars on GitHub. It checks for llms.txt among its signals, and I'm keeping the check.&lt;/p&gt;

&lt;p&gt;But I changed what the check means. It doesn't score your site higher for having the file. It flags the file as present and moves on. The signals that actually affect your score are the ones with evidence behind them: crawlability, semantic structure, content density, citation-worthy passages, and whether your pages render for bots that don't execute JavaScript.&lt;/p&gt;

&lt;p&gt;llms.txt is a 20-minute, zero-risk bet. Ship it if you want. But don't put it on your roadmap as a ranking play, don't pay anyone to "optimize" it, and don't let it displace the work that actually has evidence behind it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The bottom line
&lt;/h2&gt;

&lt;p&gt;Four independent studies covering 137,210 domains, 300,000 domains, 62,100 bot visits, and 515 million bot events all say the same thing. Google's official documentation says the same thing. John Mueller said the same thing over a year ago.&lt;/p&gt;

&lt;p&gt;llms.txt is not an AI visibility tactic. It's a developer documentation tool that got mis-sold as an SEO tool. The disappointment around it traces back to that single category error.&lt;/p&gt;

&lt;p&gt;If someone is selling you llms.txt as an AI-visibility tactic, the evidence isn't on their side.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>seo</category>
      <category>webdev</category>
      <category>opensource</category>
    </item>
    <item>
      <title>I deployed the fix, restarted, saw nothing, and reverted a change that worked</title>
      <dc:creator>Juan Camilo Auriti</dc:creator>
      <pubDate>Thu, 08 Oct 2026 11:00:00 +0000</pubDate>
      <link>https://dev.to/juanauriti/i-deployed-the-fix-restarted-saw-nothing-and-reverted-a-change-that-worked-29h1</link>
      <guid>https://dev.to/juanauriti/i-deployed-the-fix-restarted-saw-nothing-and-reverted-a-change-that-worked-29h1</guid>
      <description>&lt;p&gt;A notification wasn't firing. I found the cause, wrote a small fix, deployed it, restarted the service, triggered the condition, and watched.&lt;/p&gt;

&lt;p&gt;Nothing.&lt;/p&gt;

&lt;p&gt;So I concluded the fix was wrong, reverted it, and went looking for a different cause. I spent most of a day on the different cause. There wasn't one.&lt;/p&gt;

&lt;h2&gt;
  
  
  The suppression
&lt;/h2&gt;

&lt;p&gt;The notifier had a deduplication window:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;DEDUP_WINDOW&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;timedelta&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;minutes&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;should_send&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="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;datetime&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;bool&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;last&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;store&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="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;last&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;last&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;DEDUP_WINDOW&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;False&lt;/span&gt;          &lt;span class="c1"&gt;# same alert, too soon
&lt;/span&gt;    &lt;span class="n"&gt;store&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set&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="n"&gt;now&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;True&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Entirely reasonable. It exists so a flapping condition doesn't send forty emails.&lt;/p&gt;

&lt;p&gt;But the dedup key survived the restart — it was in shared storage, deliberately, so a restart couldn't be used to bypass suppression. Which means: my &lt;em&gt;pre-fix&lt;/em&gt; test run had already registered the key. Then I deployed, restarted, and re-triggered &lt;strong&gt;within the same five minutes&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;The fix ran. It computed the right decision. And then the dedup layer, correctly, declined to send a duplicate.&lt;/p&gt;

&lt;p&gt;Both layers behaved exactly as designed, and the composition of the two produced a result identical to the bug I was trying to fix.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why I believed the wrong thing
&lt;/h2&gt;

&lt;p&gt;The observable I was checking — "did a notification arrive" — is downstream of the suppression. It cannot distinguish these three states:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;the fix didn't work&lt;/li&gt;
&lt;li&gt;the fix worked and the send was suppressed&lt;/li&gt;
&lt;li&gt;the fix worked and the send failed for an unrelated reason&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;I read one silent outcome as one specific cause, because that's the cause I was already thinking about. Reverting was the worst possible move: it removed the correct change &lt;em&gt;and&lt;/em&gt; left the key in place, so my next test after the revert was also inside a window, which confirmed my wrong conclusion a second time.&lt;/p&gt;

&lt;p&gt;Two consistent negative results, both artifacts of my own test cadence.&lt;/p&gt;

&lt;h2&gt;
  
  
  What to check instead
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Log the suppression, at the point of suppression.&lt;/strong&gt; If the only trace of a dedup hit is the absence of an email, the layer is invisible during exactly the situations where it matters most.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;should_send&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="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;datetime&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;bool&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;last&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;store&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="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;last&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;last&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;DEDUP_WINDOW&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;log&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;info&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;suppressed&lt;/span&gt;&lt;span class="sh"&gt;"&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="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;age_s&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;now&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;last&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;total_seconds&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
                 &lt;span class="n"&gt;window_s&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;DEDUP_WINDOW&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;total_seconds&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;False&lt;/span&gt;
    &lt;span class="n"&gt;store&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set&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="n"&gt;now&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;True&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One line. It turns my whole wasted day into a log entry that says &lt;code&gt;suppressed key=... age_s=42 window_s=300&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Assert on the decision, not the delivery.&lt;/strong&gt; The unit under test is "did the notifier decide to send", which is a function you can call. Whether an email arrived is a different question involving four more systems. I had been testing the wrong end of the pipeline because it was the end I could see.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Clear the key, or use a fresh one, when verifying a deploy.&lt;/strong&gt; Either drop the dedup entry as part of the verification procedure, or trigger with a key that's never been used. Not by shortening the window in production — that changes the behaviour of the thing you're testing.&lt;/p&gt;

&lt;h2&gt;
  
  
  The general shape
&lt;/h2&gt;

&lt;p&gt;Every idempotency and rate-limiting layer converts a real event into no event. That's the feature. The cost is that &lt;strong&gt;"nothing happened" stops being evidence of anything&lt;/strong&gt; the moment one of those layers sits between you and your observable.&lt;/p&gt;

&lt;p&gt;Anywhere you have dedup, debounce, rate limits, at-most-once delivery, or cache-on-negative, post-deploy verification has to either bypass the layer or read a signal from &lt;em&gt;before&lt;/em&gt; it. Otherwise the layer will occasionally hand you a confident false negative, and a false negative that arrives twice in a row is extremely convincing.&lt;/p&gt;

&lt;p&gt;The tell I now watch for: &lt;strong&gt;I got the same negative result twice, quickly.&lt;/strong&gt; Fast repetition is the condition every suppression layer is built to detect. If two attempts a few minutes apart both showed nothing, the second one is not a confirmation — it's the most likely one to have been swallowed.&lt;/p&gt;

&lt;p&gt;Reverting a change because you couldn't see its effect is the failure mode I actually want to name here. Not seeing an effect and having no effect are different findings, and only one of them justifies a revert.&lt;/p&gt;

</description>
      <category>devops</category>
      <category>debugging</category>
      <category>monitoring</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Dependabot was watching 2 of my 6 updatable surfaces, and the config looked complete</title>
      <dc:creator>Juan Camilo Auriti</dc:creator>
      <pubDate>Wed, 07 Oct 2026 11:00:00 +0000</pubDate>
      <link>https://dev.to/juanauriti/dependabot-was-watching-2-of-my-6-updatable-surfaces-and-the-config-looked-complete-4d5d</link>
      <guid>https://dev.to/juanauriti/dependabot-was-watching-2-of-my-6-updatable-surfaces-and-the-config-looked-complete-4d5d</guid>
      <description>&lt;p&gt;My &lt;code&gt;dependabot.yml&lt;/code&gt;, which I'd have described as done:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;version&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;2&lt;/span&gt;
&lt;span class="na"&gt;updates&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;package-ecosystem&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;pip"&lt;/span&gt;
    &lt;span class="na"&gt;directory&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/"&lt;/span&gt;
    &lt;span class="na"&gt;schedule&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;interval&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;weekly"&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;package-ecosystem&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;github-actions"&lt;/span&gt;
    &lt;span class="na"&gt;directory&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/"&lt;/span&gt;
    &lt;span class="na"&gt;schedule&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;interval&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;weekly"&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Python dependencies, GitHub Actions. It's a Python project with CI. Looks complete.&lt;/p&gt;

&lt;p&gt;I ran an inventory of everything in the repo that a bot could update:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;./pyproject.toml
./frontend/package.json
./integrations/astro-geoready/package.json
./Dockerfile
./Dockerfile.web
./action.yml
./.github/actions/geo-audit/action.yml
./.github/workflows/*.yml   (6 files)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Six distinct surfaces. Two covered.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What was missing, and why each was easy to miss
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;frontend/package.json&lt;/code&gt; — npm, not in the config at all.&lt;/strong&gt; Dependabot is per-directory, and there was no npm entry anywhere. A whole frontend of dependencies, unwatched. This is the one I'd defend least: I just never added it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;integrations/astro-geoready/package.json&lt;/code&gt; — a second npm surface.&lt;/strong&gt; Adding one npm entry for &lt;code&gt;/frontend&lt;/code&gt; wouldn't have caught this. Every directory needs its own entry, and this one is a subpackage I forgot existed while writing the config for the one I was thinking about.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;Dockerfile&lt;/code&gt; and &lt;code&gt;Dockerfile.web&lt;/code&gt; — no &lt;code&gt;docker&lt;/code&gt; ecosystem entry.&lt;/strong&gt; Base image tags never get bumped. Both live in the root, so one entry covers them — but zero entries covered either.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;.github/actions/geo-audit/action.yml&lt;/code&gt; — the interesting one.&lt;/strong&gt; My &lt;code&gt;github-actions&lt;/code&gt; entry with &lt;code&gt;directory: "/"&lt;/code&gt; covers &lt;code&gt;.github/workflows/&lt;/code&gt; and the root &lt;code&gt;action.yml&lt;/code&gt;. It does &lt;strong&gt;not&lt;/strong&gt; cover a composite action nested in its own subdirectory. That file pins third-party actions, same as any workflow, and it was outside the watched set while sitting inside &lt;code&gt;.github/&lt;/code&gt;, which is precisely why I'd assumed it was covered.&lt;/p&gt;

&lt;h2&gt;
  
  
  The audit command
&lt;/h2&gt;

&lt;p&gt;Run this in any repo and compare the two lists by eye:&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;# every updatable surface&lt;/span&gt;
find &lt;span class="nb"&gt;.&lt;/span&gt; &lt;span class="se"&gt;\(&lt;/span&gt; &lt;span class="nt"&gt;-path&lt;/span&gt; ./node_modules &lt;span class="nt"&gt;-o&lt;/span&gt; &lt;span class="nt"&gt;-path&lt;/span&gt; ./.venv &lt;span class="nt"&gt;-o&lt;/span&gt; &lt;span class="nt"&gt;-path&lt;/span&gt; ./dist &lt;span class="se"&gt;\)&lt;/span&gt; &lt;span class="nt"&gt;-prune&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="se"&gt;\(&lt;/span&gt; &lt;span class="nt"&gt;-name&lt;/span&gt; &lt;span class="s1"&gt;'package.json'&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; &lt;span class="nt"&gt;-name&lt;/span&gt; &lt;span class="s1"&gt;'requirements*.txt'&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; &lt;span class="nt"&gt;-name&lt;/span&gt; &lt;span class="s1"&gt;'pyproject.toml'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
     &lt;span class="nt"&gt;-o&lt;/span&gt; &lt;span class="nt"&gt;-name&lt;/span&gt; &lt;span class="s1"&gt;'Gemfile'&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; &lt;span class="nt"&gt;-name&lt;/span&gt; &lt;span class="s1"&gt;'go.mod'&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; &lt;span class="nt"&gt;-name&lt;/span&gt; &lt;span class="s1"&gt;'Cargo.toml'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
     &lt;span class="nt"&gt;-o&lt;/span&gt; &lt;span class="nt"&gt;-name&lt;/span&gt; &lt;span class="s1"&gt;'Dockerfile*'&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; &lt;span class="nt"&gt;-name&lt;/span&gt; &lt;span class="s1"&gt;'action.yml'&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; &lt;span class="nt"&gt;-name&lt;/span&gt; &lt;span class="s1"&gt;'action.yaml'&lt;/span&gt; &lt;span class="se"&gt;\)&lt;/span&gt; &lt;span class="nt"&gt;-print&lt;/span&gt; | &lt;span class="nb"&gt;sort&lt;/span&gt;

&lt;span class="c"&gt;# what dependabot is told to watch&lt;/span&gt;
&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-E&lt;/span&gt; &lt;span class="s1"&gt;'package-ecosystem|directory'&lt;/span&gt; .github/dependabot.yml
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two commands, and the gap is the answer. The &lt;code&gt;-prune&lt;/code&gt; matters: without it &lt;code&gt;node_modules&lt;/code&gt; buries the signal under thousands of vendored &lt;code&gt;package.json&lt;/code&gt; files.&lt;/p&gt;

&lt;p&gt;The mapping, so you can read one list against the other:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;file found&lt;/th&gt;
&lt;th&gt;ecosystem&lt;/th&gt;
&lt;th&gt;&lt;code&gt;directory&lt;/code&gt;&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;pyproject.toml&lt;/code&gt;, &lt;code&gt;requirements*.txt&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;&lt;code&gt;pip&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;dir containing it&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;package.json&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;npm&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;dir containing it&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Dockerfile*&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;docker&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;dir containing it&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;.github/workflows/*&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;github-actions&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;/&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;nested &lt;code&gt;action.yml&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;&lt;code&gt;github-actions&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;the action's own dir&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;That last row is the one nobody writes down.&lt;/p&gt;

&lt;h2&gt;
  
  
  The other half: a skipped PR doesn't come back
&lt;/h2&gt;

&lt;p&gt;While fixing the config I found stale PRs from months earlier and closed a few as superseded — a patch bump that a later minor had already overtaken.&lt;/p&gt;

&lt;p&gt;Dependabot does not reopen a PR you closed, and does not re-propose a version you rejected. Closing "the old one" tells it you've decided about that update. If the newer PR isn't actually open, that dependency now sits at its current version indefinitely, with no open PR and no error.&lt;/p&gt;

&lt;p&gt;So: close superseded PRs only after confirming the superseding one exists and is open. And if you closed a batch during a cleanup, &lt;code&gt;dependabot.yml&lt;/code&gt; won't tell you — the only signal is a dependency that stopped producing PRs, which looks exactly like a dependency that's up to date.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why it looked complete
&lt;/h2&gt;

&lt;p&gt;Because &lt;code&gt;dependabot.yml&lt;/code&gt; describes what you asked for, and nothing anywhere describes what you &lt;em&gt;should&lt;/em&gt; have asked for. There's no warning for an unwatched manifest. Adding an ecosystem is opt-in per directory, and the file reads as finished the moment it covers the ecosystems you were thinking about when you wrote it.&lt;/p&gt;

&lt;p&gt;The generalisation: &lt;strong&gt;an allow-list has no error state.&lt;/strong&gt; Reviewing it will always tell you it's correct — it lists exactly what it lists. The only way to audit one is from the outside, against the set it's supposed to cover.&lt;/p&gt;

&lt;p&gt;Which is the same reason the audit above is two commands instead of one. One command reads the config. The second is the only one that can disagree with it.&lt;/p&gt;

</description>
      <category>devops</category>
      <category>github</category>
      <category>security</category>
      <category>dependabot</category>
    </item>
    <item>
      <title>input type="url" rejects the URL before your normalizer ever runs</title>
      <dc:creator>Juan Camilo Auriti</dc:creator>
      <pubDate>Sun, 04 Oct 2026 11:00:00 +0000</pubDate>
      <link>https://dev.to/juanauriti/input-typeurl-rejects-the-url-before-your-normalizer-ever-runs-1im4</link>
      <guid>https://dev.to/juanauriti/input-typeurl-rejects-the-url-before-your-normalizer-ever-runs-1im4</guid>
      <description>&lt;p&gt;I had a normalizer I was pleased with. Trims, lowercases the host, drops &lt;code&gt;www.&lt;/code&gt;, adds &lt;code&gt;https://&lt;/code&gt; when the scheme is missing, strips a trailing slash. Handles &lt;code&gt;example.com&lt;/code&gt;, &lt;code&gt;EXAMPLE.com/&lt;/code&gt;, &lt;code&gt;www.example.com/about/&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Users typing &lt;code&gt;example.com&lt;/code&gt; got a browser tooltip instead:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Please enter a URL.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The normalizer never ran. Not "ran and failed" — never executed, not once, for that input.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;input&lt;/span&gt; &lt;span class="na"&gt;type=&lt;/span&gt;&lt;span class="s"&gt;"url"&lt;/span&gt; &lt;span class="na"&gt;name=&lt;/span&gt;&lt;span class="s"&gt;"site"&lt;/span&gt; &lt;span class="na"&gt;required&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;type="url"&lt;/code&gt; attaches native constraint validation, and the spec's definition of a valid URL requires a scheme. &lt;code&gt;example.com&lt;/code&gt; has no scheme, so the input is invalid, so the form's implicit validation fails on submit — &lt;strong&gt;before&lt;/strong&gt; the &lt;code&gt;submit&lt;/code&gt; event handler where my normalizer lived.&lt;/p&gt;

&lt;p&gt;The ordering is the whole bug. Native validation runs first. Your JavaScript is downstream of a gate that already rejected the input.&lt;/p&gt;

&lt;p&gt;And a bare domain is exactly what people type. Nobody types &lt;code&gt;https://&lt;/code&gt;. So the single most common input hit a wall built into the platform, and the code written to handle it sat there looking correct.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three fixes, in the order I'd try them
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;1. Normalize before validation happens, not on submit.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Keep &lt;code&gt;type="url"&lt;/code&gt; and its keyboard and its native messaging, but fix the value while the user is still in the field:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;input&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;querySelector&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;input[name="site"]&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addEventListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;blur&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&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="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;v&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;trim&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;v&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="sr"&gt;/^&lt;/span&gt;&lt;span class="se"&gt;[&lt;/span&gt;&lt;span class="sr"&gt;a-z&lt;/span&gt;&lt;span class="se"&gt;][&lt;/span&gt;&lt;span class="sr"&gt;a-z0-9+.-&lt;/span&gt;&lt;span class="se"&gt;]&lt;/span&gt;&lt;span class="sr"&gt;*:&lt;/span&gt;&lt;span class="se"&gt;\/\/&lt;/span&gt;&lt;span class="sr"&gt;/i&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;v&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;https://&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;v&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;})&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;blur&lt;/code&gt; and not &lt;code&gt;input&lt;/code&gt;: rewriting the value on every keystroke fights the person typing, and moves the caret out from under them. On &lt;code&gt;blur&lt;/code&gt; they've finished.&lt;/p&gt;

&lt;p&gt;This is my default. It's four lines, it keeps every native behaviour, and by the time validation runs the value is valid.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Drop &lt;code&gt;type="url"&lt;/code&gt;, keep the keyboard, own the validation.&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;input&lt;/span&gt; &lt;span class="na"&gt;type=&lt;/span&gt;&lt;span class="s"&gt;"text"&lt;/span&gt; &lt;span class="na"&gt;inputmode=&lt;/span&gt;&lt;span class="s"&gt;"url"&lt;/span&gt; &lt;span class="na"&gt;autocomplete=&lt;/span&gt;&lt;span class="s"&gt;"url"&lt;/span&gt;
       &lt;span class="na"&gt;name=&lt;/span&gt;&lt;span class="s"&gt;"site"&lt;/span&gt; &lt;span class="na"&gt;required&lt;/span&gt;
       &lt;span class="na"&gt;aria-describedby=&lt;/span&gt;&lt;span class="s"&gt;"site-hint"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;p&lt;/span&gt; &lt;span class="na"&gt;id=&lt;/span&gt;&lt;span class="s"&gt;"site-hint"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;A domain or full URL, e.g. example.com&lt;span class="nt"&gt;&amp;lt;/p&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;inputmode="url"&lt;/code&gt; still gets the URL-friendly keyboard on mobile, which is most of what &lt;code&gt;type="url"&lt;/code&gt; was buying you. You now own validation, which means you own the error message too — and you must actually render one, because you just removed the browser's.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Keep &lt;code&gt;type="url"&lt;/code&gt; and replace the message with &lt;code&gt;setCustomValidity&lt;/code&gt;.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Worth knowing, but it doesn't solve this: it changes the wording of the rejection, not the rejection. Use it when the input genuinely is invalid, not to work around a value you could have fixed.&lt;/p&gt;

&lt;h2&gt;
  
  
  Don't fix it by deleting the validation
&lt;/h2&gt;

&lt;p&gt;The tempting fourth option is &lt;code&gt;novalidate&lt;/code&gt; on the form, or &lt;code&gt;type="text"&lt;/code&gt; with nothing replacing it. Both make the symptom disappear.&lt;/p&gt;

&lt;p&gt;If you do that, you owe the user everything you removed: an error message tied to the field with &lt;code&gt;aria-describedby&lt;/code&gt;, &lt;code&gt;aria-invalid="true"&lt;/code&gt; when it's wrong, focus moved to the first bad field on failed submit, and a message that says what to do rather than what went wrong. The native behaviour was doing all of that for free, including for screen readers.&lt;/p&gt;

&lt;p&gt;That's the reason fix 1 is my default: it changes &lt;em&gt;when&lt;/em&gt; the value is corrected and leaves the platform's accessibility work alone.&lt;/p&gt;

&lt;h2&gt;
  
  
  Check every surface, not the one in the bug report
&lt;/h2&gt;

&lt;p&gt;This is the part I actually got wrong. I fixed the field named in the report and shipped.&lt;/p&gt;

&lt;p&gt;There were three. The main audit form, a second form on a landing page, and a query parameter that skipped the form entirely and went straight to the API — which had its own validation and its own opinion about schemes.&lt;/p&gt;

&lt;p&gt;A normalizer is only as consistent as its least-covered entry point, and entry points multiply quietly. Enumerate them:&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;grep&lt;/span&gt; &lt;span class="nt"&gt;-rn&lt;/span&gt; &lt;span class="s1"&gt;'type="url"'&lt;/span&gt; &lt;span class="nt"&gt;--include&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'*.astro'&lt;/span&gt; &lt;span class="nt"&gt;--include&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'*.html'&lt;/span&gt; &lt;span class="nt"&gt;--include&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'*.tsx'&lt;/span&gt; &lt;span class="nt"&gt;--include&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'*.jsx'&lt;/span&gt; &lt;span class="nb"&gt;.&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then the ones without a form at all: query params, deep links, API bodies, a CLI flag, anything pasted from an email. Each is a place a bare domain can arrive.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The rule: normalize at the boundary where a string becomes your data, not at the boundary where a form is submitted.&lt;/strong&gt; One is a single function every path routes through. The other is however many forms you have, minus the ones you forgot.&lt;/p&gt;

&lt;p&gt;Which is the same rule as the trailing slash, arriving from the other side. Same function, in fact — the one that has to prepend the scheme before &lt;code&gt;urlsplit&lt;/code&gt; gets a chance to read the whole thing as a path.&lt;/p&gt;

</description>
      <category>html</category>
      <category>javascript</category>
      <category>webdev</category>
      <category>a11y</category>
    </item>
    <item>
      <title>My filter matched 0 of 1545 rows in production, and the test fixture was the reason nobody noticed</title>
      <dc:creator>Juan Camilo Auriti</dc:creator>
      <pubDate>Sat, 03 Oct 2026 11:00:00 +0000</pubDate>
      <link>https://dev.to/juanauriti/my-filter-matched-0-of-1545-rows-in-production-and-the-test-fixture-was-the-reason-nobody-noticed-l4d</link>
      <guid>https://dev.to/juanauriti/my-filter-matched-0-of-1545-rows-in-production-and-the-test-fixture-was-the-reason-nobody-noticed-l4d</guid>
      <description>&lt;p&gt;A feature was shipping nothing. Not erroring, not slow — the list it was supposed to populate was empty, for everyone.&lt;/p&gt;

&lt;p&gt;The cause was a filter matching on a string prefix:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;is_actionable&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;rec&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="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;bool&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;rec&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;FIX:&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;In production, &lt;strong&gt;0 of 1545 rows&lt;/strong&gt; started with &lt;code&gt;FIX:&lt;/code&gt;. The prefix had been dropped upstream months earlier, in a change that had nothing to do with this code and looked entirely safe.&lt;/p&gt;

&lt;h2&gt;
  
  
  The test that passed
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;test_is_actionable&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
    &lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="nf"&gt;is_actionable&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;FIX: add a canonical tag&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="nf"&gt;is_actionable&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;INFO: nothing to do here&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;That test is green, and will stay green forever, and tells you nothing.&lt;/p&gt;

&lt;p&gt;Because I wrote the fixture by reading the filter. The string &lt;code&gt;"FIX: add a canonical tag"&lt;/code&gt; exists in my repository for exactly one reason: the function I was testing looks for &lt;code&gt;FIX:&lt;/code&gt;. It is not a sample of anything. It's the function's own precondition, restated as data and then asserted on.&lt;/p&gt;

&lt;p&gt;So the test verifies that &lt;code&gt;startswith&lt;/code&gt; works. Python already had that covered.&lt;/p&gt;

&lt;h2&gt;
  
  
  The shape of the bug
&lt;/h2&gt;

&lt;p&gt;The filter has two halves and only one of them is in the code:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Given a string with the prefix, do the right thing.&lt;/strong&gt; In the function. Tested. Correct.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Production strings have the prefix.&lt;/strong&gt; Nowhere. Untested. False.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Half two is an assumption about a &lt;em&gt;different system&lt;/em&gt; — whatever produces those records. When that system changed, nothing in my repository was watching, because nothing in my repository contained a real example of its output.&lt;/p&gt;

&lt;p&gt;A fixture written from the code under test can only ever check half one. That's the whole failure mode, and it's invisible from inside the test file, which reads as perfectly reasonable.&lt;/p&gt;

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

&lt;p&gt;Not more unit tests. A test on the &lt;em&gt;population&lt;/em&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;test_filter_matches_some_real_records&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;total&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;count&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;records&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="n"&gt;total&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;no records to check against&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="n"&gt;matched&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;count&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;records&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;where&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;is_actionable&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="n"&gt;matched&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&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;is_actionable() matched 0 of &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;total&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; real records — &lt;/span&gt;&lt;span class="sh"&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;the shape it expects no longer exists upstream&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;Three things about this that took me a couple of tries.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;assert total &amp;gt; 0&lt;/code&gt; first.&lt;/strong&gt; Without it, an empty table makes the second assertion vacuously... well, it makes it fail, which sounds fine, but the message is then misleading — it says the filter is wrong when the fixture is empty. Guard the denominator and the failure tells you which of the two things broke.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;matched &amp;gt; 0&lt;/code&gt;, not a percentage.&lt;/strong&gt; I wanted &lt;code&gt;matched / total &amp;gt; 0.1&lt;/code&gt; and I was wrong to. The ratio is a business fact that legitimately drifts, and a threshold on it produces a test that fails on a good day. Zero is the only number that unambiguously means &lt;em&gt;the contract is gone&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;It has to run against real shapes.&lt;/strong&gt; A sample of production data, an anonymised snapshot, a staging DB, or the smallest thing that works: a handful of real strings copied out of production into a fixture file, with a comment saying where they came from and when. Not strings you wrote — strings the upstream system wrote.&lt;/p&gt;

&lt;h2&gt;
  
  
  The rule I took out of it
&lt;/h2&gt;

&lt;blockquote&gt;
&lt;p&gt;A fixture derived from the code under test can prove the code is self-consistent. It cannot prove the code is right about the world.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Every filter, parser, regex, or mapping that consumes data from somewhere else has this second half, and the second half is where the outages live — because it breaks without anyone touching your file.&lt;/p&gt;

&lt;p&gt;The cheap version, if you add nothing else: &lt;strong&gt;one assertion, anywhere in your suite, that the filter matches at least one real record.&lt;/strong&gt; It catches the entire class, and unlike a unit test it fails when the world changes rather than when you change.&lt;/p&gt;

&lt;h2&gt;
  
  
  How I'd find the next one
&lt;/h2&gt;

&lt;p&gt;Grep your codebase for string-shape assumptions about foreign data — &lt;code&gt;startswith&lt;/code&gt;, &lt;code&gt;endswith&lt;/code&gt;, a hardcoded key, a regex with a literal prefix, an enum comparison against a string from an API:&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;grep&lt;/span&gt; &lt;span class="nt"&gt;-rnE&lt;/span&gt; &lt;span class="s1"&gt;'\.startswith\(|\.endswith\(|re\.(match|search)\(r?"[^"]*\^'&lt;/span&gt; &lt;span class="nt"&gt;--include&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'*.py'&lt;/span&gt; &lt;span class="nb"&gt;.&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For each hit, ask the question that isn't in the test file: &lt;em&gt;what proves that shape still arrives?&lt;/em&gt; If the answer is a fixture you wrote, you have this bug and it's dormant, not absent.&lt;/p&gt;

&lt;p&gt;Mine was dormant for months. The tests were green the whole time, and green tests are how it stayed dormant.&lt;/p&gt;

</description>
      <category>testing</category>
      <category>python</category>
      <category>webdev</category>
      <category>debugging</category>
    </item>
    <item>
      <title>Git bundle verify said "complete history". git clone exited 0. The working tree was empty</title>
      <dc:creator>Juan Camilo Auriti</dc:creator>
      <pubDate>Fri, 02 Oct 2026 11:00:00 +0000</pubDate>
      <link>https://dev.to/juanauriti/git-bundle-verify-said-complete-history-git-clone-exited-0-the-working-tree-was-empty-1k9o</link>
      <guid>https://dev.to/juanauriti/git-bundle-verify-said-complete-history-git-clone-exited-0-the-working-tree-was-empty-1k9o</guid>
      <description>&lt;p&gt;I needed to move commits between two machines with no network route between them, so: &lt;code&gt;git bundle&lt;/code&gt;. Write the bundle, copy it across, clone from it.&lt;/p&gt;

&lt;p&gt;I checked my work the way you're supposed to:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;git bundle verify transfer.bundle
&lt;span class="go"&gt;9c824386e56b3c496c45823acc195283c024bccc refs/heads/main
The bundle records a complete history.
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;em&gt;Complete history.&lt;/em&gt; Then:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;git clone transfer.bundle restored
&lt;span class="go"&gt;Cloning into 'restored'...
warning: remote HEAD refers to nonexistent ref, unable to checkout.
&lt;/span&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="nv"&gt;$?&lt;/span&gt;
&lt;span class="go"&gt;0
&lt;/span&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;ls &lt;/span&gt;restored
&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Exit status &lt;strong&gt;0&lt;/strong&gt;. A &lt;code&gt;.git&lt;/code&gt; directory. No branches. No files.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the bundle was missing
&lt;/h2&gt;

&lt;p&gt;A &lt;code&gt;HEAD&lt;/code&gt;. That's it.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;git bundle list-heads transfer.bundle
&lt;span class="go"&gt;9c824386e... refs/heads/main
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One ref. &lt;code&gt;git clone&lt;/code&gt; needs to know which ref to check out, and it resolves that through the bundle's &lt;code&gt;HEAD&lt;/code&gt;. There isn't one, so it falls back to a default that the bundle doesn't contain — you can see the wreckage afterwards:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;git &lt;span class="nt"&gt;-C&lt;/span&gt; restored symbolic-ref HEAD
&lt;span class="go"&gt;refs/heads/master
&lt;/span&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;git &lt;span class="nt"&gt;-C&lt;/span&gt; restored branch
&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;HEAD&lt;/code&gt; points at &lt;code&gt;refs/heads/master&lt;/code&gt;, which does not exist, because the bundle carried &lt;code&gt;main&lt;/code&gt;. The objects are all there. Nothing is corrupt. &lt;code&gt;git log main&lt;/code&gt; inside that clone works fine. There is simply no branch and no checkout.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I assumed, and why it was wrong
&lt;/h2&gt;

&lt;p&gt;I had this filed in my head as &lt;em&gt;"don't build bundles from &lt;code&gt;refs/remotes&lt;/code&gt;"&lt;/em&gt; — I'd made the bundle from &lt;code&gt;refs/remotes/origin/main&lt;/code&gt; the first time, and blamed that.&lt;/p&gt;

&lt;p&gt;Wrong. I tested both:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;bundle created from&lt;/th&gt;
&lt;th&gt;&lt;code&gt;verify&lt;/code&gt;&lt;/th&gt;
&lt;th&gt;
&lt;code&gt;clone&lt;/code&gt; exit&lt;/th&gt;
&lt;th&gt;files&lt;/th&gt;
&lt;th&gt;branch&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;refs/remotes/origin/main&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;complete history&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;none&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;main&lt;/code&gt; (i.e. &lt;code&gt;refs/heads/main&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;complete history&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;none&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;--all HEAD&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;complete history&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;&lt;code&gt;main&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The local ref behaves exactly as badly. The &lt;code&gt;refs/remotes&lt;/code&gt; detail was a coincidence I'd turned into a rule. The actual requirement is the &lt;code&gt;HEAD&lt;/code&gt; ref, and nothing in &lt;code&gt;verify&lt;/code&gt;'s output is sensitive to it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The incantation
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git bundle create transfer.bundle &lt;span class="nt"&gt;--all&lt;/span&gt; HEAD
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;--all&lt;/code&gt; takes every ref; &lt;code&gt;HEAD&lt;/code&gt; adds the one that makes it clonable. That bundle lists four heads including &lt;code&gt;HEAD&lt;/code&gt;, and the clone checks out a working tree with a branch on it.&lt;/p&gt;

&lt;p&gt;If you want one branch rather than everything:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git bundle create transfer.bundle main HEAD
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Why this is worse than a failure
&lt;/h2&gt;

&lt;p&gt;Every signal available to a script says success.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;git bundle verify&lt;/code&gt; exits 0 and prints a sentence containing the word &lt;em&gt;complete&lt;/em&gt;. &lt;code&gt;git clone&lt;/code&gt; exits 0. Under &lt;code&gt;set -e&lt;/code&gt;, nothing stops. The only evidence is a &lt;code&gt;warning:&lt;/code&gt; on &lt;strong&gt;stderr&lt;/strong&gt;, and a transfer script that logs stdout — or pipes through &lt;code&gt;tail&lt;/code&gt;, or runs under a CI step that only surfaces failures — will never show it to anyone.&lt;/p&gt;

&lt;p&gt;The failure lands later, somewhere unrelated, as "the repo on the other machine is empty."&lt;/p&gt;

&lt;h2&gt;
  
  
  The check that costs nothing
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;verify&lt;/code&gt; answers &lt;em&gt;"are the objects intact"&lt;/em&gt;. It does not answer &lt;em&gt;"is this clonable"&lt;/em&gt;. So assert the thing you actually want, after cloning:&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;set&lt;/span&gt; &lt;span class="nt"&gt;-euo&lt;/span&gt; pipefail

git bundle create &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$B&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;--all&lt;/span&gt; HEAD
git bundle verify &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$B&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;

git bundle list-heads &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$B&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-qw&lt;/span&gt; HEAD &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt; &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"bundle has no HEAD ref, clone would produce an empty tree"&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&amp;amp;2&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nb"&gt;exit &lt;/span&gt;1&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="o"&gt;}&lt;/span&gt;

git clone &lt;span class="nt"&gt;-q&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$B&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$DEST&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
git &lt;span class="nt"&gt;-C&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$DEST&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; rev-parse &lt;span class="nt"&gt;--verify&lt;/span&gt; HEAD &lt;span class="o"&gt;&amp;gt;&lt;/span&gt;/dev/null &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt; &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"cloned repo has no resolvable HEAD"&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&amp;amp;2&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nb"&gt;exit &lt;/span&gt;1&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two lines of assertion, and they're the two questions &lt;code&gt;verify&lt;/code&gt; doesn't answer: &lt;em&gt;is there a HEAD in the bundle&lt;/em&gt;, and &lt;em&gt;did the clone end up on a commit&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;The general shape, which is the part worth keeping: &lt;strong&gt;a verify step that checks integrity is not a verify step that checks usability.&lt;/strong&gt; Anything you move between machines wants an assertion on the property you actually need at the destination — the objects being intact is necessary and nowhere near sufficient.&lt;/p&gt;

&lt;p&gt;Tested on git 2.34.1. If your version prints something friendlier than a stderr warning, good — mine didn't, and the exit code is 0 either way.&lt;/p&gt;

</description>
      <category>git</category>
      <category>devops</category>
      <category>bash</category>
      <category>opensource</category>
    </item>
    <item>
      <title>How I close a monthly dataset, and the month I found out mine had been lying</title>
      <dc:creator>Juan Camilo Auriti</dc:creator>
      <pubDate>Thu, 01 Oct 2026 11:00:00 +0000</pubDate>
      <link>https://dev.to/juanauriti/how-i-close-a-monthly-dataset-and-the-month-i-found-out-mine-had-been-lying-168m</link>
      <guid>https://dev.to/juanauriti/how-i-close-a-monthly-dataset-and-the-month-i-found-out-mine-had-been-lying-168m</guid>
      <description>&lt;p&gt;I publish a monthly report from my own audit data. First of the month, one edition, findings only from the window that just closed.&lt;/p&gt;

&lt;p&gt;The rules that make it publishable are almost entirely rules about what gets &lt;strong&gt;thrown out&lt;/strong&gt;. I learned most of them the expensive way, and one of them by discovering that a number I had already published was computed from a table where the majority of rows were an artifact of a bug.&lt;/p&gt;

&lt;h2&gt;
  
  
  The rule that came first
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;No numerical findings before the data exists.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;That sounds too obvious to write down. It isn't, because the pressure runs the other way: you have a publication slot on the first of the month, an outline, a headline shape you like, and a dataset that hasn't closed. It is very easy to write &lt;em&gt;"early data suggests"&lt;/em&gt; around a number you don't have yet and backfill it.&lt;/p&gt;

&lt;p&gt;So the rule is mechanical. The window closes on the last day of the month. Nothing gets written before that. If the data doesn't support a section, the section doesn't ship — not "ships with a hedge."&lt;/p&gt;

&lt;p&gt;The hedge is the tell. &lt;em&gt;"Early signals indicate"&lt;/em&gt;, &lt;em&gt;"we're seeing"&lt;/em&gt;, &lt;em&gt;"trending toward"&lt;/em&gt; — every one of those is a sentence written before the number existed, and I have yet to write one that turned out to be worth keeping.&lt;/p&gt;

&lt;h2&gt;
  
  
  The month the dataset was lying
&lt;/h2&gt;

&lt;p&gt;I found a normalization bug: two spellings of the same URL living as separate rows, and a scheduled job that read one and wrote the other, so it re-audited forever. The mechanism has its own post earlier in this series. What matters here is the number that fell out of the cleanup.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;86.9% of the rows in the audit table were produced by that loop.&lt;/strong&gt; One domain alone had 1831 rows; after the fix and the merge it had 19.&lt;/p&gt;

&lt;p&gt;Every aggregate I had computed from that table was weighted by which domains happened to be caught in the loop. Not slightly wrong — wrong in a way that has no relationship to the question I was asking, because the loop's victims were selected by URL spelling, which correlates with nothing.&lt;/p&gt;

&lt;p&gt;I had already published from it.&lt;/p&gt;

&lt;h2&gt;
  
  
  What that added to the rules
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Distinct entities, not rows.&lt;/strong&gt; Every aggregate counts distinct domains, never raw rows. If one domain can contribute 1831 rows to a mean, the mean is that domain's opinion.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="c1"&gt;-- one observation per domain per window, most recent wins&lt;/span&gt;
&lt;span class="k"&gt;WITH&lt;/span&gt; &lt;span class="n"&gt;per_domain&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="k"&gt;DISTINCT&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;domain_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;domain_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;score&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;created_at&lt;/span&gt;
  &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;reports&lt;/span&gt;
  &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;created_at&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="n"&gt;window_start&lt;/span&gt; &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;created_at&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="n"&gt;window_end&lt;/span&gt;
  &lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;domain_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;created_at&lt;/span&gt; &lt;span class="k"&gt;DESC&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="k"&gt;count&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;domains&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;count&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;score&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;scored&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;round&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;avg&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;score&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="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;mean_score&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;per_domain&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;DISTINCT ON&lt;/code&gt; with a matching &lt;code&gt;ORDER BY&lt;/code&gt; is the whole guard. Drop the &lt;code&gt;ORDER BY&lt;/code&gt; and Postgres gives you an arbitrary row per domain, which is a different and much subtler bug than the one I was fixing.&lt;/p&gt;

&lt;p&gt;And note there are &lt;strong&gt;two&lt;/strong&gt; counts in that select, which is not padding. &lt;code&gt;count(*)&lt;/code&gt; counts domains; &lt;code&gt;avg(score)&lt;/code&gt; silently skips NULLs. Run it on a window where three domains produced a score and one didn't, and you get &lt;code&gt;domains = 4&lt;/code&gt; next to a mean computed from three values. Publish the first number as the denominator of the second and you have overstated your sample in exactly the way this whole post is about — with a query I wrote &lt;em&gt;for&lt;/em&gt; this post, which is how I found it. &lt;code&gt;count(score)&lt;/code&gt; is the honest denominator for anything derived from &lt;code&gt;score&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Publish the denominator.&lt;/strong&gt; Every figure ships with the count behind it — and it has to be the count of values that fed &lt;em&gt;that&lt;/em&gt; figure. It's the number a reader needs to weigh the claim, and — more usefully — it's the number that would have caught my bug. A mean over "1963 reports" and a mean over "19 domains" look nothing alike, and I would have noticed the first time the denominator was two orders of magnitude off from my mental model.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A minimum n, decided before looking.&lt;/strong&gt; Segments below it get reported as "not enough data" rather than as a number with a caveat. Deciding the floor after seeing the segments is how you end up with a floor that happens to include the interesting result.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;One derived story at a time, capped.&lt;/strong&gt; A closed dataset can produce endless spin-offs, and each one is a chance to re-slice until something looks significant. A hard cap per edition — mine is three — makes that a budget instead of a temptation.&lt;/p&gt;

&lt;h2&gt;
  
  
  What gets thrown out, concretely
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Runaway rows&lt;/strong&gt; — anything from a job that didn't converge. Not de-weighted. Deleted, then the aggregate re-run.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Self-audits.&lt;/strong&gt; My own domains, my staging environments, the test fixtures that point at real URLs. They cluster at high scores for obvious reasons and they were in my table.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Single-domain outliers driving a segment.&lt;/strong&gt; If removing one domain moves a segment's headline, the segment isn't a finding, it's that domain.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Anything I can't recompute.&lt;/strong&gt; If I can't re-derive a figure from the closed window with a query I can paste into the report's own notes, it doesn't go in. This one kills more sentences than all the others together, and it kills the ones I'm most attached to — the ones I "remember" from looking at a dashboard.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The uncomfortable part
&lt;/h2&gt;

&lt;p&gt;The bug didn't come from carelessness in the analysis. It came from the analysis trusting the storage layer, which was written months earlier by someone with different concerns — me, not thinking about aggregates.&lt;/p&gt;

&lt;p&gt;There's no rule that prevents that. The closest I have is: &lt;strong&gt;before the first aggregate of a new window, count the rows and count the distinct entities, and look at the ratio.&lt;/strong&gt; If it's not roughly what you'd expect, stop and find out why before computing anything else.&lt;/p&gt;

&lt;p&gt;Mine was 1963 to 19 in the worst case. I never looked, because the ratio isn't a finding — it's plumbing, and plumbing doesn't make it into an outline.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;The report itself goes out today, on my own site. This post is the method, not the findings — the numbers live there.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>data</category>
      <category>sql</category>
      <category>opensource</category>
      <category>webdev</category>
    </item>
    <item>
      <title>78% of my reports had nothing to say, and I spent a month tuning the wrong thing</title>
      <dc:creator>Juan Camilo Auriti</dc:creator>
      <pubDate>Wed, 30 Sep 2026 11:00:00 +0000</pubDate>
      <link>https://dev.to/juanauriti/78-of-my-reports-had-nothing-to-say-and-i-spent-a-month-tuning-the-wrong-thing-dk</link>
      <guid>https://dev.to/juanauriti/78-of-my-reports-had-nothing-to-say-and-i-spent-a-month-tuning-the-wrong-thing-dk</guid>
      <description>&lt;p&gt;I looked at what my monitoring product had actually been sending people.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;78% of reports showed a delta of exactly zero.&lt;/strong&gt; Nothing had changed since the previous run. The report was correct, on time, and empty.&lt;/p&gt;

&lt;p&gt;My first reading was that my change-detection thresholds were too coarse — that real movement was happening below the granularity I was measuring, and a finer scale would surface it. I spent a while on that. I wrote about the alert side of it earlier in this series, and about the backtest that talked me out of the tuning; this is the part I got wrong underneath it, which is different and worse.&lt;/p&gt;

&lt;p&gt;The thresholds weren't the problem. &lt;strong&gt;The artifact was the problem.&lt;/strong&gt; A report whose content is "no change" is not a report with a sensitivity issue. It's a report with nothing in it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why "no change" is the expected state
&lt;/h2&gt;

&lt;p&gt;Once you say it out loud it's almost embarrassing.&lt;/p&gt;

&lt;p&gt;Someone signs up, runs an audit, gets a list of problems, fixes some of them, and stops. That's the &lt;em&gt;success&lt;/em&gt; path. Their site is now static in the dimensions I measure, because sites are static in those dimensions. &lt;code&gt;robots.txt&lt;/code&gt; doesn't drift. Schema markup doesn't decay. A well-formed &lt;code&gt;llms.txt&lt;/code&gt; stays well-formed until somebody edits it, and nobody edits it.&lt;/p&gt;

&lt;p&gt;So the population my weekly report addresses is mostly people for whom the honest answer is "nothing happened, and that's fine." Which is not a message worth an email, and definitely not worth a dashboard visit.&lt;/p&gt;

&lt;p&gt;The uncomfortable implication: &lt;strong&gt;a monitoring product for a slow-moving signal has no natural weekly cadence.&lt;/strong&gt; I had assumed one because monitoring products have weekly reports. That's an industry convention, not a property of my data.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd actually measure now
&lt;/h2&gt;

&lt;p&gt;The metric I was missing isn't a threshold. It's a property of the output:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;What fraction of generated artifacts contain at least one thing the recipient didn't already know?&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Call it content rate. Mine was 22%. Every product decision downstream looks different once you know that number, and none of them are threshold changes:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Change the cadence to match the signal.&lt;/strong&gt; Not weekly because it's Tuesday. Send when there's something, and say so — "we watched, nothing moved" is a fine thing to put in a monthly digest and a bad thing to put in a weekly email.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Add signals that actually move.&lt;/strong&gt; The things I was measuring are properties of a site the owner controls, so they change only when the owner acts. Competitor position, whether a citation appeared or disappeared, a new crawler user-agent showing up in the logs — those move without the user doing anything, which is exactly what makes them worth telling someone.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Make stability an explicit product claim, or drop it.&lt;/strong&gt; "Nothing changed" is genuinely valuable information &lt;em&gt;if&lt;/em&gt; the product is framed around confirming that. It's dead weight if the product is framed around finding problems. I had the second framing and the first output.&lt;/p&gt;

&lt;h2&gt;
  
  
  The measurement, if you want to run it on yours
&lt;/h2&gt;

&lt;p&gt;If you generate a recurring artifact — report, digest, alert summary, weekly email — this is the query. Mine is scores, yours is whatever the artifact is about:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;WITH&lt;/span&gt; &lt;span class="n"&gt;ranked&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="k"&gt;SELECT&lt;/span&gt;
    &lt;span class="n"&gt;domain_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;score&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;LAG&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;score&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;OVER&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;PARTITION&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;domain_id&lt;/span&gt; &lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;created_at&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;prev&lt;/span&gt;
  &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;reports&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt;
  &lt;span class="k"&gt;count&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;                                        &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;reports&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="k"&gt;count&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;FILTER&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;prev&lt;/span&gt; &lt;span class="k"&gt;IS&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;DISTINCT&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;score&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;unchanged&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;round&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="k"&gt;count&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;FILTER&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;prev&lt;/span&gt; &lt;span class="k"&gt;IS&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;DISTINCT&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;score&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="k"&gt;count&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
                                                  &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;pct_unchanged&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;ranked&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;prev&lt;/span&gt; &lt;span class="k"&gt;IS&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;IS NOT DISTINCT FROM&lt;/code&gt; rather than &lt;code&gt;=&lt;/code&gt; is the detail that matters: &lt;code&gt;NULL = NULL&lt;/code&gt; is &lt;code&gt;NULL&lt;/code&gt;, which is not true, so a plain &lt;code&gt;=&lt;/code&gt; silently drops every pair where a score was missing — and missing scores cluster exactly where something odd is happening. &lt;code&gt;IS NOT DISTINCT FROM&lt;/code&gt; treats two NULLs as equal and gives you the honest count.&lt;/p&gt;

&lt;p&gt;Then read the number as a statement about your product rather than your thresholds. If most of your artifacts are empty, no amount of sensitivity tuning fills them.&lt;/p&gt;

&lt;h2&gt;
  
  
  The part I'd tell myself in advance
&lt;/h2&gt;

&lt;p&gt;I spent weeks on threshold work because threshold work &lt;em&gt;feels&lt;/em&gt; like the right kind of effort. It's measurable, it's in code, it produces diffs, and at the end of a day of it you can point at something.&lt;/p&gt;

&lt;p&gt;Asking "should this report exist at all" produces no diff. It's also the question that was load-bearing, and I avoided it for a month by staying busy adjacent to it.&lt;/p&gt;

&lt;p&gt;The tell, in hindsight: I was tuning the sensitivity of a detector without ever having measured how often the thing it detects occurs. That ordering is backwards, and it's a cheap check — one query, before the tuning, not after.&lt;/p&gt;

</description>
      <category>saas</category>
      <category>monitoring</category>
      <category>webdev</category>
      <category>productivity</category>
    </item>
    <item>
      <title>FastAPI's detail has two shapes, and the second one shows your users [object Object]</title>
      <dc:creator>Juan Camilo Auriti</dc:creator>
      <pubDate>Tue, 29 Sep 2026 11:00:00 +0000</pubDate>
      <link>https://dev.to/juanauriti/fastapis-detail-has-two-shapes-and-the-second-one-shows-your-users-object-object-40ck</link>
      <guid>https://dev.to/juanauriti/fastapis-detail-has-two-shapes-and-the-second-one-shows-your-users-object-object-40ck</guid>
      <description>&lt;p&gt;A user hit a validation error on a form and the page told them:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="err"&gt;object&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;Object&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The frontend line responsible is one you have written, probably today:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nx"&gt;errorEl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;textContent&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;detail&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Something went wrong&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That line is correct for every error my API raises deliberately, and broken for an entire class of error I never wrote.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two shapes
&lt;/h2&gt;

&lt;p&gt;When you raise it yourself, &lt;code&gt;detail&lt;/code&gt; is whatever you passed — normally a string:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;HTTPException&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;status_code&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;403&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;detail&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;This domain isn&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;t yours&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;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"detail"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"This domain isn't yours"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When Pydantic rejects the request body, FastAPI builds the 422 for you, and &lt;code&gt;detail&lt;/code&gt; is an &lt;strong&gt;array of objects&lt;/strong&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;AuditRequest&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;BaseModel&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;HttpUrl&lt;/span&gt;

    &lt;span class="nd"&gt;@field_validator&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;url&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="nd"&gt;@classmethod&lt;/span&gt;
    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;no_private_hosts&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;cls&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;v&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;is_private&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;v&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;host&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
            &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;ValueError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;private addresses are not allowed&lt;/span&gt;&lt;span class="sh"&gt;"&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;v&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"detail"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"value_error"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"loc"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"body"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"url"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"msg"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Value error, private addresses are not allowed"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"input"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"http://169.254.169.254/"&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Same key. Same status family. Two incompatible types.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the &lt;code&gt;||&lt;/code&gt; doesn't save you
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;data.detail || fallback&lt;/code&gt; is a truthiness check, and a non-empty array is truthy. So the fallback never fires — the array wins, and &lt;code&gt;textContent&lt;/code&gt; stringifies it with &lt;code&gt;Array.prototype.toString&lt;/code&gt;, which joins the elements, each of which stringifies as &lt;code&gt;[object Object]&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;One validation error prints &lt;code&gt;[object Object]&lt;/code&gt;. Two print &lt;code&gt;[object Object],[object Object]&lt;/code&gt;, which at least looks broken enough that someone files a bug.&lt;/p&gt;

&lt;p&gt;This is the specific reason it survives to production: the failure needs a request that is &lt;em&gt;malformed&lt;/em&gt;, not merely &lt;em&gt;rejected&lt;/em&gt;. Every hand-raised error works. Every happy path works. Your tests assert on status codes and on &lt;code&gt;detail&lt;/code&gt; for the errors you wrote. Nobody writes a test asserting on how a validator's error message renders in the DOM.&lt;/p&gt;

&lt;h2&gt;
  
  
  The unroller
&lt;/h2&gt;

&lt;p&gt;Handle both shapes once, at the boundary where the response becomes a message:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;extractError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;fallback&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Something went wrong&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;d&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;detail&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;typeof&lt;/span&gt; &lt;span class="nx"&gt;d&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;string&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;d&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;trim&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;d&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;Array&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;isArray&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;d&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;msgs&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;d&lt;/span&gt;
      &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;field&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Array&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;isArray&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;loc&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;loc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;p&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;p&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;body&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;.&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;""&lt;/span&gt;
        &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;msg&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;typeof&lt;/span&gt; &lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;msg&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;string&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;msg&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;""&lt;/span&gt;
        &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;msg&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="dl"&gt;""&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;field&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;field&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;msg&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;msg&lt;/span&gt;
      &lt;span class="p"&gt;})&lt;/span&gt;
      &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;Boolean&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;msgs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;msgs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt; · &lt;/span&gt;&lt;span class="dl"&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;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;typeof&lt;/span&gt; &lt;span class="nx"&gt;d&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;string&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;fallback&lt;/span&gt;   &lt;span class="c1"&gt;// empty/whitespace string&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;fallback&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The parts that are there because I got them wrong first:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;loc.filter(p =&amp;gt; p !== "body")&lt;/code&gt;.&lt;/strong&gt; The raw &lt;code&gt;loc&lt;/code&gt; is &lt;code&gt;["body", "url"]&lt;/code&gt;. Showing a user &lt;code&gt;body.url: ...&lt;/code&gt; leaks your request schema into the UI for no benefit. Drop the container, keep the field.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Guard every element.&lt;/strong&gt; &lt;code&gt;e.msg&lt;/code&gt; is a string in every FastAPI version I've used, but this function's entire job is to survive a shape it didn't expect. A &lt;code&gt;.map&lt;/code&gt; that assumes will throw inside your error handler, which is the worst possible place to throw.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;.filter(Boolean)&lt;/code&gt; then check &lt;code&gt;length&lt;/code&gt;.&lt;/strong&gt; If every element yields an empty message you must fall through to the fallback, not return &lt;code&gt;""&lt;/code&gt; — an empty string assigned to &lt;code&gt;textContent&lt;/code&gt; produces a silent, blank error state, which is worse than the wrong message because nobody reports it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Trim the string case.&lt;/strong&gt; &lt;code&gt;detail: "  "&lt;/code&gt; is truthy and renders as nothing. Same silent-blank failure.&lt;/p&gt;

&lt;h2&gt;
  
  
  Or flatten it server-side
&lt;/h2&gt;

&lt;p&gt;If you own both ends, the other fix is to stop emitting the union at all:&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;fastapi&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;FastAPI&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Request&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;fastapi.exceptions&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;RequestValidationError&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;fastapi.responses&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;JSONResponse&lt;/span&gt;

&lt;span class="nd"&gt;@app.exception_handler&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;RequestValidationError&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;validation_handler&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Request&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;exc&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;RequestValidationError&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;parts&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;exc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;errors&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
        &lt;span class="n"&gt;field&lt;/span&gt; &lt;span class="o"&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;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;str&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;loc&lt;/span&gt;&lt;span class="sh"&gt;"&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;p&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;body&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;parts&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="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;field&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;e&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;msg&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;field&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;msg&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;JSONResponse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;status_code&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;422&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;content&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;detail&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="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;parts&lt;/span&gt;&lt;span class="p"&gt;)})&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now &lt;code&gt;detail&lt;/code&gt; is always a string and the original one-liner is correct.&lt;/p&gt;

&lt;p&gt;I did both. The handler is the real fix — one type, one contract. &lt;code&gt;extractError&lt;/code&gt; stays because a frontend that trusts a single shape is one upstream change away from &lt;code&gt;[object Object]&lt;/code&gt; again, and because third-party middleware can produce a 422 that never reaches my handler.&lt;/p&gt;

&lt;h2&gt;
  
  
  The transferable bit
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;detail&lt;/code&gt; is a documented union type that reads like a scalar. FastAPI isn't doing anything wrong; it's in the spec. The trap is that JavaScript's truthiness makes a union look handled when only one arm of it is.&lt;/p&gt;

&lt;p&gt;So: anywhere you write &lt;code&gt;x || fallback&lt;/code&gt; against a value that crosses a network boundary, the question isn't &lt;em&gt;"can it be missing"&lt;/em&gt; — &lt;code&gt;||&lt;/code&gt; has that covered. It's &lt;strong&gt;"can it be present and the wrong type"&lt;/strong&gt;. That's the case &lt;code&gt;||&lt;/code&gt; silently passes through to the DOM.&lt;/p&gt;

&lt;p&gt;Fastest way to find yours: point your form's own validator at a malformed request and look at what your UI actually says. Mine said &lt;code&gt;[object Object]&lt;/code&gt; for weeks, in a field a user reads only when they're already stuck.&lt;/p&gt;

</description>
      <category>fastapi</category>
      <category>python</category>
      <category>javascript</category>
      <category>webdev</category>
    </item>
  </channel>
</rss>
